4cf7a786d6
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
240 lines
8.2 KiB
Dart
240 lines
8.2 KiB
Dart
// 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'),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|