App structure: a swipe-in menu shell; pages/, link/, actions/; Settings
The shell (shell.dart): a drawer from the left edge or each page's menu button. Dash, Signals, Captures; Tools (Camera, Link test, Orin power options), the rarely used ones, after pirate-station-aide; Settings (gateway address, dark/bright, build-time theme and landing). Landing view at build time: YUKART_HOME=dash|signals, signals for now. Dash, Signals and Captures stay alive while hidden; Captures has its own navigator, so a run survives switching views. Back goes inside Captures, then to the landing view, then out. The mock banner sits over every page. main.dart is startup only; the old home screen is pages/signals.dart; dialogs and sheets moved to actions/; the protocol client, USB binding and Reading to link/. lib/README.md catalogues it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz
This commit is contained in:
+239
@@ -0,0 +1,239 @@
|
||||
// The shell: one page at a time, chosen from a menu that swipes in from the left
|
||||
// (or opens from each page's menu button). Main views first, then Tools, the rarely
|
||||
// used ones (pirate-station-aide's pattern), then Settings.
|
||||
//
|
||||
// Dash, Signals and Captures stay alive while hidden, so a capture run survives a look
|
||||
// at the dash; Camera and Link test are built only while shown (the camera holds a stream).
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'actions/orin_power.dart';
|
||||
import 'link/boundary.dart';
|
||||
import 'link/net.dart';
|
||||
import 'pages/camera.dart';
|
||||
import 'pages/checklist.dart';
|
||||
import 'pages/dash.dart';
|
||||
import 'pages/link_test.dart';
|
||||
import 'pages/settings.dart';
|
||||
import 'pages/signals.dart';
|
||||
import 'shell_scope.dart';
|
||||
import 'theme/palette.dart';
|
||||
import 'widgets/link_status.dart';
|
||||
import 'widgets/mock_banner.dart';
|
||||
|
||||
class AppShell extends StatefulWidget {
|
||||
const AppShell({
|
||||
super.key,
|
||||
required this.boundary,
|
||||
required this.usb,
|
||||
required this.linkTest,
|
||||
required this.themes,
|
||||
});
|
||||
final Boundary boundary;
|
||||
final UsbLink usb;
|
||||
final LinkTest linkTest;
|
||||
final Themes themes;
|
||||
|
||||
@override
|
||||
State<AppShell> createState() => _AppShellState();
|
||||
}
|
||||
|
||||
class _AppShellState extends State<AppShell> {
|
||||
final _scaffold = GlobalKey<ScaffoldState>();
|
||||
// Captures runs its own navigator (list → checklist), so the menu stays reachable
|
||||
// inside it and the run survives switching views.
|
||||
final _captures = GlobalKey<NavigatorState>();
|
||||
final Set<ShellView> _built = {shellView.value};
|
||||
|
||||
void _go(ShellView v) {
|
||||
shellView.value = v;
|
||||
_scaffold.currentState?.closeDrawer();
|
||||
}
|
||||
|
||||
Widget _page(ShellView v) {
|
||||
final b = widget.boundary;
|
||||
return switch (v) {
|
||||
ShellView.dash => DashPage(boundary: b, themes: widget.themes),
|
||||
ShellView.signals => SignalsPage(boundary: b, usb: widget.usb),
|
||||
ShellView.captures => Navigator(
|
||||
key: _captures,
|
||||
onGenerateRoute: (_) => MaterialPageRoute<void>(
|
||||
builder: (_) => ChecklistsPage(boundary: b),
|
||||
),
|
||||
),
|
||||
ShellView.camera => CamViewPage(host: Uri.parse(b.url).host),
|
||||
ShellView.linkTest => LinkTestPage(
|
||||
test: widget.linkTest,
|
||||
host: Uri.parse(b.url).host,
|
||||
restoreBinding: () => widget.usb.bindFor(Uri.parse(b.url)),
|
||||
),
|
||||
ShellView.settings => SettingsPage(
|
||||
boundary: b,
|
||||
usb: widget.usb,
|
||||
themes: widget.themes,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
static const _kept = {ShellView.dash, ShellView.signals, ShellView.captures};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: shellView,
|
||||
builder: (context, current, _) {
|
||||
_built.add(current);
|
||||
return PopScope(
|
||||
// Back: inside Captures first, then to the landing view, then out of the app.
|
||||
canPop:
|
||||
current == landingView &&
|
||||
!(_captures.currentState?.canPop() ?? false),
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) return;
|
||||
if (current == ShellView.captures &&
|
||||
(_captures.currentState?.canPop() ?? false)) {
|
||||
_captures.currentState!.pop();
|
||||
} else {
|
||||
shellView.value = landingView;
|
||||
}
|
||||
},
|
||||
child: ShellScope(
|
||||
openMenu: () => _scaffold.currentState?.openDrawer(),
|
||||
child: Scaffold(
|
||||
key: _scaffold,
|
||||
drawerEdgeDragWidth: 32,
|
||||
drawer: _Menu(
|
||||
boundary: widget.boundary,
|
||||
current: current,
|
||||
go: _go,
|
||||
// From the shell's context: the drawer's is gone once it closes.
|
||||
power: () {
|
||||
_scaffold.currentState?.closeDrawer();
|
||||
orinPowerOptions(context, widget.boundary);
|
||||
},
|
||||
),
|
||||
body: ListenableBuilder(
|
||||
listenable: widget.boundary,
|
||||
builder: (context, child) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (widget.boundary.mock)
|
||||
const SafeArea(bottom: false, child: MockBanner()),
|
||||
// Pages draw their own top (app bars); keep every bottom clear of the system bar.
|
||||
Expanded(child: SafeArea(top: false, child: child!)),
|
||||
],
|
||||
),
|
||||
child: IndexedStack(
|
||||
index: ShellView.values.indexOf(current),
|
||||
children: [
|
||||
for (final v in ShellView.values)
|
||||
// Kept views, once built, stay built; the rest exist only while shown.
|
||||
if ((_kept.contains(v) && _built.contains(v)) ||
|
||||
v == current)
|
||||
_page(v)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Menu extends StatelessWidget {
|
||||
const _Menu({
|
||||
required this.boundary,
|
||||
required this.current,
|
||||
required this.go,
|
||||
required this.power,
|
||||
});
|
||||
final Boundary boundary;
|
||||
final ShellView current;
|
||||
final void Function(ShellView) go;
|
||||
final VoidCallback power;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = AppTheme.of(context);
|
||||
Widget item(ShellView v, IconData icon, String label) => ListTile(
|
||||
leading: Icon(icon),
|
||||
title: Text(label),
|
||||
selected: v == current,
|
||||
selectedColor: p.accent,
|
||||
selectedTileColor: p.accent.withValues(alpha: 0.12),
|
||||
onTap: () => go(v),
|
||||
);
|
||||
Widget heading(String t) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(
|
||||
t,
|
||||
style: Theme.of(context).textTheme.labelLarge
|
||||
?.copyWith(color: p.subdued),
|
||||
),
|
||||
);
|
||||
return Drawer(
|
||||
backgroundColor: p.surface,
|
||||
child: SafeArea(
|
||||
child: ListenableBuilder(
|
||||
listenable: boundary,
|
||||
builder: (context, _) {
|
||||
final b = boundary;
|
||||
final host = b.hello?['host'] as String?;
|
||||
return ListView(
|
||||
children: [
|
||||
// The link, always: tapping it goes to Settings, where the address is.
|
||||
InkWell(
|
||||
onTap: () => go(ShellView.settings),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Yukart',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
LinkChip(boundary: b),
|
||||
const SizedBox(height: 4),
|
||||
Text(host ?? b.url, style: TextStyle(color: p.subdued)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
item(ShellView.dash, Icons.speed, 'Dash'),
|
||||
item(ShellView.signals, Icons.list_alt, 'Signals'),
|
||||
item(ShellView.captures, Icons.checklist, 'Captures'),
|
||||
const Divider(),
|
||||
heading('Tools'),
|
||||
item(ShellView.camera, Icons.videocam, 'Camera'),
|
||||
item(ShellView.linkTest, Icons.network_check, 'Link test'),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.power_settings_new),
|
||||
title: const Text('Orin power options'),
|
||||
subtitle:
|
||||
b.state == LinkState.up && b.hello?['boot_id'] is String
|
||||
? null
|
||||
: Text(
|
||||
'needs the link up',
|
||||
style: TextStyle(color: p.subdued),
|
||||
),
|
||||
enabled:
|
||||
b.state == LinkState.up && b.hello?['boot_id'] is String,
|
||||
onTap: power,
|
||||
),
|
||||
const Divider(),
|
||||
item(ShellView.settings, Icons.settings, 'Settings'),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user