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

191 lines
8.8 KiB
Markdown

# 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:**
```dart
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.