191 lines
8.8 KiB
Markdown
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.
|