Add app source and tests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
alvocool
2026-10-11 10:15:30 +03:00
parent 7e246255c2
commit 38881d1fe1
135 changed files with 21057 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
abstract class ConnectServiceEditor {
/// A local project folder was chosen for the build step.
void onProjectDirectoryPicked(String path);
}

View File

@@ -0,0 +1,16 @@
import 'package:flutter/cupertino.dart';
import '../../about/internal/application/ServiceConfig.dart';
import 'ServiceEditorState.dart';
/// Edits a single service belonging to a server (jar service, web app or a
/// generic binary).
class ServiceEditor extends StatefulWidget {
/// null = create new.
final ServiceConfig? service;
const ServiceEditor({super.key, this.service});
@override
State<ServiceEditor> createState() => ServiceEditorState();
}

View File

@@ -0,0 +1,454 @@
import 'package:flutter/material.dart';
import 'package:stacked/stacked.dart';
import '../../about/internal/application/ServiceConfig.dart';
import '../../about/internal/application/TextType.dart';
import '../../designs/input/InputFields.dart';
import '../../designs/text/Text.dart';
import '../../utils/Colors.dart';
import '../../utils/Validators.dart';
import 'ConnectServiceEditor.dart';
import 'ServiceEditor.dart';
import 'ViewServiceEditor.dart';
class ServiceEditorState extends State<ServiceEditor>
implements ConnectServiceEditor {
ViewServiceEditor? _model;
final _formKey = GlobalKey<FormState>();
late final ServiceConfig _svc;
late final TextEditingController _projectPath;
@override
void initState() {
super.initState();
_svc = widget.service?.copy() ?? ServiceConfig();
_projectPath = TextEditingController(text: _svc.localProjectPath);
}
@override
void dispose() {
_projectPath.dispose();
super.dispose();
}
bool get _isNew => widget.service == null;
bool get _isWeb => _svc.kind == ServiceKind.webApp;
bool get _isGeneric => _svc.kind == ServiceKind.generic;
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewServiceEditor>.reactive(
viewModelBuilder: () => ViewServiceEditor(context, this),
onViewModelReady: _onReady,
builder: (context, viewModel, child) => Scaffold(
appBar: AppBar(
title: text(
_isNew ? 'New service' : 'Edit service',
20,
TextType.Bold,
color: context.jp.text,
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 12),
child: FilledButton.icon(
onPressed: _onSave,
icon: const Icon(Icons.check, size: 18),
label: text('Save', 14, TextType.Bold, color: colorWhite),
),
),
],
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 40),
children: [
inputField(
label: 'Service label',
hint: _isWeb ? 'marketing-site' : 'orders-api',
initial: _svc.name,
onSaved: _onSaveName,
validator: requiredField,
),
inputSection(context, 'Deployment type'),
_kindSelector(),
if (_isWeb)
..._webFields()
else if (_isGeneric)
..._genericFields()
else
..._jarFields(),
inputSection(context, 'Build from source (optional)'),
_sectionHint(_buildBlurb(viewModel)),
Padding(
padding: const EdgeInsets.only(bottom: 14),
child: TextFormField(
controller: _projectPath,
decoration: InputDecoration(
labelText: 'Local project path',
hintText: r'C:\code\app or /home/me/app',
suffixIcon: IconButton(
tooltip: 'Choose folder',
icon: const Icon(Icons.folder_open),
onPressed: _onPickProjectDir,
),
),
),
),
inputField(
label: 'Build command (optional)',
hint: _buildCommandHint(),
initial: _svc.buildCommand,
onSaved: _onSaveBuildCommand,
),
if (_isWeb)
inputField(
label: 'Build output dir (optional)',
hint: 'blank → auto (dist/ · build/ · build/web)',
initial: _svc.outputSubdir,
onSaved: _onSaveOutputSubdir,
)
else
inputField(
label: _isGeneric
? 'Built artifact path (optional)'
: 'Built jar pattern (optional)',
hint: _isGeneric
? 'e.g. target/release/myapp (explicit path)'
: 'blank → target/*.jar or build/libs/*.jar',
initial: _svc.jarPattern,
onSaved: _onSaveJarPattern,
),
..._healthSection(),
],
),
),
),
);
}
void _onReady(ViewServiceEditor model) {
_model = model;
// onViewModelReady fires mid-build; a synchronous setState from the
// first load would dirty this State while it is still being built.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _initiate();
});
}
Future<void> _initiate() async {}
String _buildBlurb(ViewServiceEditor model) {
if (!model.canBuildLocally) return 'Local builds run on desktop only.';
return _isWeb
? 'Point at the local project and Peach builds the site, then '
'publishes it to nginx.'
: 'Point at the local project and Peach builds the jar '
'(Maven/Gradle) before deploying.';
}
String _buildCommandHint() {
if (_isWeb) {
final fallback = _svc.framework.defaultBuild;
return 'blank → ${fallback.isEmpty ? "none" : fallback}';
}
return _isGeneric
? 'e.g. make build / cargo build --release'
: 'blank → mvn -DskipTests package / gradlew build';
}
// ── named handlers ─────────────────────────────────────────────────────────
void _onSaveName(String v) => _svc.name = v;
void _onSaveBuildCommand(String v) => _svc.buildCommand = v;
void _onSaveOutputSubdir(String v) => _svc.outputSubdir = v;
void _onSaveJarPattern(String v) => _svc.jarPattern = v;
void _onSaveRemoteDir(String v) => _svc.remoteDir = v;
void _onSaveRemoteJarName(String v) => _svc.remoteJarName = v;
void _onSaveServiceName(String v) => _svc.serviceName = v;
void _onSaveLogCommand(String v) => _svc.logCommand = v;
void _onSaveNginxService(String v) =>
_svc.nginxService = v.trim().isEmpty ? 'nginx' : v;
void _onSaveHealthTarget(String v) => _svc.healthTarget = v;
void _onPickProjectDir() => _model?.pickProjectDirectory();
void _onSave() {
if (!_formKey.currentState!.validate()) return;
_formKey.currentState!.save();
_svc.localProjectPath = _projectPath.text.trim();
Navigator.of(context).pop(_svc);
}
/// Switching kind moves the remote directory to that kind's default, but
/// only when the user hasn't typed their own.
void _onSelectKind(ServiceKind kind) {
setState(() {
_svc.kind = kind;
final dir = _svc.remoteDir.trim();
if (kind == ServiceKind.webApp && (dir.isEmpty || dir == '/opt/app')) {
_svc.remoteDir = '/var/www/html';
} else if (kind != ServiceKind.webApp && dir == '/var/www/html') {
_svc.remoteDir = '/opt/app';
}
});
}
void _onSelectFramework(WebFramework f) => setState(() => _svc.framework = f);
void _onSelectHealthType(HealthCheckType h) =>
setState(() => _svc.healthType = h);
// ── ConnectServiceEditor ───────────────────────────────────────────────────
@override
void onProjectDirectoryPicked(String path) {
if (!mounted) return;
setState(() => _projectPath.text = path);
}
// ── screen-only widgets ────────────────────────────────────────────────────
List<Widget> _healthSection() => [
inputSection(context, 'Health check'),
_sectionHint(
'How the Health dashboard probes this service. "systemd (auto)" '
'checks the unit is active.',
),
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: HealthCheckType.values
.map(
(h) => _choice(
h.label,
_svc.healthType == h,
() => _onSelectHealthType(h),
),
)
.toList(),
),
),
if (_svc.healthType == HealthCheckType.http ||
_svc.healthType == HealthCheckType.tcp ||
_svc.healthType == HealthCheckType.command)
inputField(
label: switch (_svc.healthType) {
HealthCheckType.http => 'Health URL',
HealthCheckType.tcp => 'Port',
_ => 'Health command',
},
hint: switch (_svc.healthType) {
HealthCheckType.http => 'http://localhost:8080/health',
HealthCheckType.tcp => '8080',
_ => 'systemctl is-active myapp',
},
initial: _svc.healthTarget,
onSaved: _onSaveHealthTarget,
),
];
Widget _kindSelector() {
return Wrap(
spacing: 12,
runSpacing: 12,
children: [
_kindCard(
ServiceKind.jarService,
Icons.settings_suggest,
'Jar service',
'systemd + jar',
),
_kindCard(
ServiceKind.webApp,
Icons.language,
'Web app',
'static → nginx',
),
_kindCard(ServiceKind.generic, Icons.terminal, 'Generic', 'any binary'),
],
);
}
Widget _kindCard(ServiceKind kind, IconData icon, String title, String sub) {
final jp = context.jp;
final active = _svc.kind == kind;
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _onSelectKind(kind),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
width: 150,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: active ? PeachTheme.seed.withValues(alpha: 0.12) : jp.surface2,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: active ? PeachTheme.seed : jp.border,
width: active ? 1.6 : 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: active ? PeachTheme.seed : jp.textDim),
const SizedBox(height: 10),
text(title, 14, TextType.Bold, color: jp.text),
const SizedBox(height: 2),
text(sub, 11.5, TextType.Regular, color: jp.textFaint),
],
),
),
);
}
Widget _frameworkSelector() {
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8),
child: text(
'Framework',
13,
TextType.Regular,
color: context.jp.textDim,
),
),
Wrap(
spacing: 8,
runSpacing: 8,
children: WebFramework.values
.map(
(f) => _choice(
f.label,
_svc.framework == f,
() => _onSelectFramework(f),
),
)
.toList(),
),
],
),
);
}
Widget _choice(String label, bool active, VoidCallback onSelected) {
final jp = context.jp;
return ChoiceChip(
selected: active,
label: Text(label),
onSelected: (_) => onSelected(),
showCheckmark: false,
selectedColor: PeachTheme.seed.withValues(alpha: 0.20),
backgroundColor: jp.surface2,
side: BorderSide(color: active ? PeachTheme.seed : jp.border),
labelStyle: TextStyle(
color: active ? PeachTheme.seed : jp.textDim,
fontWeight: FontWeight.w600,
),
);
}
List<Widget> _jarFields() => [
inputSection(context, 'Jar service'),
inputField(
label: 'Remote directory',
hint: '/opt/orders',
initial: _svc.remoteDir,
onSaved: _onSaveRemoteDir,
validator: validRemotePath,
),
inputField(
label: 'Jar filename on server (optional)',
hint: 'orders.jar — blank keeps the dropped name',
initial: _svc.remoteJarName,
onSaved: _onSaveRemoteJarName,
),
inputField(
label: 'systemd unit name (optional)',
hint: 'orders → systemctl restart orders',
initial: _svc.serviceName,
onSaved: _onSaveServiceName,
),
inputField(
label: 'Log command (optional)',
hint: 'blank → journalctl -u <unit> -n 200',
initial: _svc.logCommand,
onSaved: _onSaveLogCommand,
),
];
List<Widget> _webFields() => [
const SizedBox(height: 6),
_frameworkSelector(),
inputField(
label: 'nginx web root',
hint: '/var/www/marketing',
initial: _svc.remoteDir,
onSaved: _onSaveRemoteDir,
validator: validRemotePath,
),
inputField(
label: 'nginx unit name',
hint: 'nginx',
initial: _svc.nginxService,
onSaved: _onSaveNginxService,
),
inputField(
label: 'Log command (optional)',
hint: 'blank → journalctl -u nginx -n 200',
initial: _svc.logCommand,
onSaved: _onSaveLogCommand,
),
];
List<Widget> _genericFields() => [
inputSection(context, 'Service (any binary / script)'),
inputField(
label: 'Remote directory',
hint: '/opt/myapp',
initial: _svc.remoteDir,
onSaved: _onSaveRemoteDir,
validator: validRemotePath,
),
inputField(
label: 'Target filename on server',
hint: 'myapp (the uploaded file is chmod +x)',
initial: _svc.remoteJarName,
onSaved: _onSaveRemoteJarName,
),
inputField(
label: 'systemd unit name (optional)',
hint: 'myapp → systemctl restart myapp',
initial: _svc.serviceName,
onSaved: _onSaveServiceName,
),
inputField(
label: 'Log command (optional)',
hint: 'blank → journalctl -u <unit> -n 200',
initial: _svc.logCommand,
onSaved: _onSaveLogCommand,
),
];
Widget _sectionHint(String message) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: text(message, 12.5, TextType.Regular, color: context.jp.textFaint),
);
}

View File

@@ -0,0 +1,26 @@
import 'package:file_picker/file_picker.dart';
import '../parent/ParentViewModel.dart';
import 'ConnectServiceEditor.dart';
class ViewServiceEditor extends ParentViewModel {
ConnectServiceEditor connection;
ViewServiceEditor(super.context, this.connection);
/// True on desktop, where a local build can actually run.
bool get canBuildLocally => getDataManager().buildSupported;
/// Opens the OS folder picker for the service's source project.
void pickProjectDirectory() async {
try {
final directory = await FilePicker.getDirectoryPath(
dialogTitle: 'Select the service project folder',
);
if (directory == null) return;
connection.onProjectDirectoryPicked(directory);
} catch (error) {
handleError(error, pickProjectDirectory, () {}, "Retry");
}
}
}