Add project config and docs
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
190
ARCHITECTURE.md
Normal file
190
ARCHITECTURE.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user