Files
PeachDeployments/ARCHITECTURE.md
alvocool 7e246255c2 Add project config and docs
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 10:15:26 +03:00

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) via ViewModelBuilder.reactive.
  • Transport: SSH/SFTP (dartssh2) — Peach talks to servers, not an HTTP API.
  • Storage: shared_preferences for config, flutter_secure_storage (OS keystore) for passwords.
  • Codegen: Mason brick for screens (§6).
  • Core idea: every screen is 4 files; one ParentViewModel centralises loading/error/reachability; one AppDataManager is 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 to retry and returns false. This is the SSH equivalent of the reference's connectivity hasNetwork — Peach targets LAN/VPN boxes where a device-level internet check proves nothing.

  • handleError(error, retry, close, buttonName) — the standard catch. Closes loading, then maps the failure onto a PeachError:

    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). positiveAction must Navigator.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

  1. No logic in builder: before return. Derived values are state fields or getters, set in _initiate().

  2. Named event handlers only — onPressed: _onSave, never onPressed: () => _save() (a closure is fine only when it carries an argument, e.g. () => _onDeploy(svc)).

  3. onViewModelReady assigns _model and calls one named _initiate().

  4. Never build raw Text/TextStyle for display copy — use text(…) from designs/text/Text.dart with a TextType.

    TextType picks a font family, not a weight — Bold→Sora, Regular→Urbanist, Light→PlusJakartaSans. Weight is an independent axis: pass weight: for a heavy body string rather than reaching for TextType.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; and SelectableText.

  5. Never hardcode a hex colour. Named colours live in utils/Colors.dart; brightness-aware surfaces come from context.jp (JpColors). Terminal surfaces use ConsoleColors — a fixed palette that stays dark in both themes, because a console doesn't invert.

  6. Reuse designs/ before building. Screen-only widgets are private methods at the bottom of that screen's State; anything reusable moves to designs/.

  7. Formatting helpers live in utils/CommonUtils.dart (formatBytes, formatCount, timeAgo, baseName, joinRemote…) — never inline them.

  8. Navigation goes through PeachNavigation with a NavigatorType; destinations are widget instances, not route strings. Use navigateToPageWithData when the parent must refresh on return.