Summary
Split the single Flutter package (app/) into a Dart pub workspace with two standalone apps sharing a common package:
pino/
├── pubspec.yaml # workspace: [apps/*, packages/*]
├── packages/
│ └── pino_shared/ # shared lib (theme + DTOs/protocol today; store+transport later)
├── apps/
│ ├── pino_mobile/ # CLIENT app: pairing + chat (iOS/Android)
│ └── pino_desktop/ # CONTROL app: server control (macOS)
└── server/
Why
The mobile and desktop apps are two different products (per SPEC-03 "two roles, one codebase"):
- Mobile = client: scans QR, pairs, chats with sessions.
- Desktop = control app: runs/stops/restarts the server, generates/regenerates QR, revokes devices.
They are already almost fully decoupled in code today — the only shared import is app/lib/app/theme.dart; desktop imports zero mobile layers and vice-versa. The only thing fusing them is one pubspec.yaml and the Platform.isMacOS branch in main.dart.
Splitting now (while separation is cheap) buys:
- Disjoint dependency sets — mobile stops shipping macOS-only
tray_manager/window_manager; desktop stops shipping camera/QR-scan/go_router/pairing deps.
- No
Platform.isMacOS branch — each app has its own main().
- Structural guarantee the pairing/chat flow can never appear on desktop.
- A
pino_shared seam ready for the future "desktop chat" work (store/transport become shared then).
Deferred to now-B (done): the desktop control app was wired in the single package first (Phase 4) so we have a verified, running app + green tests as the safety net for this refactor.
Scope
packages/pino_shared
- Move
app/lib/app/theme.dart (and any genuinely shared DTOs/protocol) here.
- Keep it small; grow it when desktop-chat needs
store/ + transport/.
apps/pino_mobile
- Everything in today's
app/ except lib/control/ and lib/desktop/.
- Keeps
ios/ + android/ runners, camera/go_router/notifications/pairing deps.
- Its own
main() (delete the Platform.isMacOS branch).
apps/pino_desktop
- Move
lib/control/, lib/desktop/ (incl. daemon/, desktop_controller.dart, desktop_app.dart, screens/, tray/) + their colocated tests.
- Keeps the
macos/ runner (tray_manager + window_manager plugin registration).
main() calls runDesktopApp() directly (no platform branch).
- Deps:
tray_manager, window_manager, qr_flutter, flutter_riverpod.
Root
pubspec.yaml with workspace: listing apps/* + packages/* (Dart pub workspaces; no melos).
- Update CI to build/test each app; update
docs/specs/README.md Flutter path notes.
The real cost (call out for the implementer)
The Dart-level move is mechanical (imports already disjoint). The heavy part is platform-runner scaffolding: splitting app/macos into pino_desktop and app/ios + app/android into pino_mobile — bundle IDs, Runner.xcodeproj, GeneratedPluginRegistrant, Podfiles. These need GUI/Xcode/Gradle verification (flutter build macos, flutter build ios, flutter build apk).
Acceptance criteria
Pointers
- SPEC-03:
docs/specs/SPEC-03-desktop-control-app.md (see "Two roles, one codebase").
- Desktop code to move:
app/lib/control/, app/lib/desktop/.
- Shared today:
app/lib/app/theme.dart.
- Coupling to delete:
Platform.isMacOS branch in app/lib/main.dart.
Summary
Split the single Flutter package (
app/) into a Dart pub workspace with two standalone apps sharing a common package:Why
The mobile and desktop apps are two different products (per SPEC-03 "two roles, one codebase"):
They are already almost fully decoupled in code today — the only shared import is
app/lib/app/theme.dart; desktop imports zero mobile layers and vice-versa. The only thing fusing them is onepubspec.yamland thePlatform.isMacOSbranch inmain.dart.Splitting now (while separation is cheap) buys:
tray_manager/window_manager; desktop stops shippingcamera/QR-scan/go_router/pairing deps.Platform.isMacOSbranch — each app has its ownmain().pino_sharedseam ready for the future "desktop chat" work (store/transport become shared then).Deferred to now-B (done): the desktop control app was wired in the single package first (Phase 4) so we have a verified, running app + green tests as the safety net for this refactor.
Scope
packages/pino_sharedapp/lib/app/theme.dart(and any genuinely shared DTOs/protocol) here.store/+transport/.apps/pino_mobileapp/exceptlib/control/andlib/desktop/.ios/+android/runners, camera/go_router/notifications/pairing deps.main()(delete thePlatform.isMacOSbranch).apps/pino_desktoplib/control/,lib/desktop/(incl.daemon/,desktop_controller.dart,desktop_app.dart,screens/,tray/) + their colocated tests.macos/runner (tray_manager + window_manager plugin registration).main()callsrunDesktopApp()directly (no platform branch).tray_manager,window_manager,qr_flutter,flutter_riverpod.Root
pubspec.yamlwithworkspace:listingapps/*+packages/*(Dart pub workspaces; no melos).docs/specs/README.mdFlutter path notes.The real cost (call out for the implementer)
The Dart-level move is mechanical (imports already disjoint). The heavy part is platform-runner scaffolding: splitting
app/macosintopino_desktopandapp/ios+app/androidintopino_mobile— bundle IDs,Runner.xcodeproj,GeneratedPluginRegistrant, Podfiles. These need GUI/Xcode/Gradle verification (flutter build macos,flutter build ios,flutter build apk).Acceptance criteria
apps/pino_mobile,apps/pino_desktop,packages/pino_sharedresolve.pino_mobilepubspec has notray_manager/window_manager;pino_desktoppubspec has no camera/QR-scan/pairing deps.Platform.isMacOSapp-selection branch anywhere; each app has its ownmain().flutter analyze --fatal-infosclean in every package.flutter build macos(desktop) andflutter build ios+apk(mobile) succeed.docs/specs/SPEC-03-desktop-control-app.md"Two roles, one codebase" section updated to describe the workspace layout.Pointers
docs/specs/SPEC-03-desktop-control-app.md(see "Two roles, one codebase").app/lib/control/,app/lib/desktop/.app/lib/app/theme.dart.Platform.isMacOSbranch inapp/lib/main.dart.