Add app source and tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
4
lib/Peach/see/service/ConnectServiceEditor.dart
Normal file
4
lib/Peach/see/service/ConnectServiceEditor.dart
Normal file
@@ -0,0 +1,4 @@
|
||||
abstract class ConnectServiceEditor {
|
||||
/// A local project folder was chosen for the build step.
|
||||
void onProjectDirectoryPicked(String path);
|
||||
}
|
||||
16
lib/Peach/see/service/ServiceEditor.dart
Normal file
16
lib/Peach/see/service/ServiceEditor.dart
Normal 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();
|
||||
}
|
||||
454
lib/Peach/see/service/ServiceEditorState.dart
Normal file
454
lib/Peach/see/service/ServiceEditorState.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
26
lib/Peach/see/service/ViewServiceEditor.dart
Normal file
26
lib/Peach/see/service/ViewServiceEditor.dart
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user