8.8 KiB
Peach — frontend architecture
Peach follows the Autoreceptive Flutter architecture (Frontend/Receptive as
the reference base). Everything below is enforced convention, not suggestion —
new code that ignores it will look wrong next to the rest.
1. Stack
- Flutter, state management
stacked(MVVM) viaViewModelBuilder.reactive. - Transport: SSH/SFTP (
dartssh2) — Peach talks to servers, not an HTTP API. - Storage:
shared_preferencesfor config,flutter_secure_storage(OS keystore) for passwords. - Codegen: Mason brick for screens (§6).
- Core idea: every screen is 4 files; one
ParentViewModelcentralises loading/error/reachability; oneAppDataManageris the single data gateway.
2. Folder structure (lib/Peach/)
lib/Peach/
about/ # models (one class per file, named after the class)
external/data/ # what comes back FROM a server (Docker, Kafka, Redis, Health…)
external/initial/ # what we send TO a server (SystemdSpec)
internal/application/ # app-owned models + enums (ServerProfile, ServiceConfig,
# DeploymentRecord, StepTracker, NavigatorType, TextType…)
internal/file/ # local file work (BuildService, WebArchive, FileStorage)
comms/ # the wire
ConnectComms.dart # abstract contract: every remote call Peach can make
Comms.dart # SshService — the one implementation
informatics/ # data gateway
DataManager.dart # abstract: ConnectInternalMemory + ConnectComms + ConnectFileStorage
AppDataManager.dart # the single concrete DataManager (pure delegation)
memory/ # local persistence
ConnectInternalMemory.dart / InternalMemory.dart
ConfigStore · DeploymentStore · SecretStore
configs/ # Navigator.dart (PeachNavigation), ThemeController.dart
designs/ # the design system — reuse before building
Responsive · LogConsole · StepList · StatCard · TransferCard · OutputSheet …
text/Text.dart (the `text()` helper) input/InputFields.dart navigation/SideNav.dart
see/ # screens — one folder per feature
parent/ParentViewModel.dart
<feature>/{Foo.dart, FooState.dart, ViewFoo.dart, ConnectFoo.dart}
utils/ # Colors · CommonUtils · Validators
main.dart
3. The 4-file screen pattern (mandatory)
| File | Role |
|---|---|
Foo.dart |
StatefulWidget shell — only creates FooState. |
FooState.dart |
State<Foo> — all UI + local state; implements ConnectFoo. Built with ViewModelBuilder.reactive. |
ViewFoo.dart |
ViewModel extends ParentViewModel — all logic; calls the DataManager; pushes results back through ConnectFoo. Never touches widgets. |
ConnectFoo.dart |
The callback interface between the two. |
Wiring:
class FooState extends State<Foo> implements ConnectFoo {
ViewFoo? _model;
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewFoo>.reactive(
viewModelBuilder: () => ViewFoo(context, this),
onViewModelReady: _onReady, // assign + one named init
builder: (context, viewModel, child) => Scaffold(…), // NO logic before return
);
}
void _onReady(ViewFoo model) { _model = model; _initiate(); }
Future<void> _initiate() async { _model?.loadThings(); }
@override
void onThingsLoaded(List<Thing> things) { setState(() => _things = things); }
}
4. ParentViewModel — what every ViewModel inherits
Constructed once per screen; builds the single
AppDataManager(InternalMemory(), SshService(), FileStorage()).
-
showLoading(text)/closeLoading()— full-screen loading overlay. -
canReach(server, retry)— opens (or reuses) an SSH session; on failure raises the server-unreachable overlay wired toretryand returnsfalse. This is the SSH equivalent of the reference's connectivityhasNetwork— Peach targets LAN/VPN boxes where a device-level internet check proves nothing. -
handleError(error, retry, close, buttonName)— the standardcatch. Closes loading, then maps the failure onto aPeachError:Failure Result SSHAuthFailError/SSHAuthAbortError"Authentication failed" (critical) SocketException/SSHStateError"Connection failed" — host/port/VPN hints TimeoutException"Timed out" SSHError"SSH error" with the raw detail as helper FileSystemException"File error" — local artifact unreadable anything else generic PeachError -
showApplicationNotification(type, title, description, drag, dismissible, close, {action, positiveAction})— modal bottom sheet used for all confirmations (delete, install, clear).positiveActionmustNavigator.pop(context)first.
5. Data layer
DataManager is the union of three contracts, so a screen never reaches for a
service directly:
ConnectComms→SshService— deploy, systemd, docker, kafka, redis, files, logs.ConnectInternalMemory→InternalMemory— servers, deployment history, theme.ConnectFileStorage→FileStorage— local build, artifact read, web packaging.
Streams (logs, stepStream, transferProgress, buildLogs) are exposed as
getters on the ViewModel and subscribed to by the State — a stream of remote
output has no request/response shape to hand back one call at a time. Every
command still goes through the ViewModel.
6. Code generation
dart pub global activate mason_cli # once, if mason is missing
mason get # once per clone
Adding a screen
mason make mvvc_template # project: Peach, screen: MyScreen
Generates the four files under lib/Peach/see/myscreen/, pre-wired with the
Responsive mobile/tablet/desktop split. Don't hand-scaffold them.
Adding a persisted field
mason make internal_memory # field: pinnedServers, type: List<ServerProfile>
Injects the getter/setter pair into all three files that have to agree —
ConnectInternalMemory (contract), InternalMemory (implementation + its
storage key) and AppDataManager (delegation) — handling JSON for objects and
lists, and putting secrets in the OS keystore under a random-suffixed key when
you answer yes to "is this a secret".
Passing a generic type on the command line:
<and>are shell redirection, so--request_object "List<Foo>"gets eaten before mason sees it and fails with "The system cannot find the file specified". Either answer the interactive prompt, or pass a config file:echo '{"function_name":"pinnedServers","request_object":"List<ServerProfile>","secure":false}' > vars.json mason make internal_memory -c vars.json
Run it from the project root — the hook edits paths relative to there. It's idempotent: re-running for an existing field detects the duplicate and skips.
7. Hard conventions
-
No logic in
builder:beforereturn. Derived values are state fields or getters, set in_initiate(). -
Named event handlers only —
onPressed: _onSave, neveronPressed: () => _save()(a closure is fine only when it carries an argument, e.g.() => _onDeploy(svc)). -
onViewModelReadyassigns_modeland calls one named_initiate(). -
Never build raw
Text/TextStylefor display copy — usetext(…)fromdesigns/text/Text.dartwith aTextType.TextTypepicks a font family, not a weight —Bold→Sora,Regular→Urbanist,Light→PlusJakartaSans. Weight is an independent axis: passweight:for a heavy body string rather than reaching forTextType.Bold, which would reface it in the display font.Three things are deliberately not routed through
text(): Material slot children (SnackBar(content:),DropdownMenuItem(child:),Chip(label:),ListTile(title:)) which the theme already styles;fontFamily: 'monospace'terminal content; andSelectableText. -
Never hardcode a hex colour. Named colours live in
utils/Colors.dart; brightness-aware surfaces come fromcontext.jp(JpColors). Terminal surfaces useConsoleColors— a fixed palette that stays dark in both themes, because a console doesn't invert. -
Reuse
designs/before building. Screen-only widgets are private methods at the bottom of that screen'sState; anything reusable moves todesigns/. -
Formatting helpers live in
utils/CommonUtils.dart(formatBytes,formatCount,timeAgo,baseName,joinRemote…) — never inline them. -
Navigation goes through
PeachNavigationwith aNavigatorType; destinations are widget instances, not route strings. UsenavigateToPageWithDatawhen the parent must refresh on return.