Skip to content

Commit 8c1deb4

Browse files
committed
[green] integrate startup recovery and offline draft controls
Signed-off-by: Viwat Vchirawongkwin <viwat.v@chula.ac.th>
1 parent a79c15b commit 8c1deb4

10 files changed

Lines changed: 860 additions & 103 deletions

File tree

‎app/lib/app/app.dart‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,22 @@ library;
1515
import 'package:flutter/material.dart';
1616

1717
import 'package:pyble/localization/localization.dart';
18+
import 'package:pyble/editor/recovery_gate.dart';
1819
import 'package:pyble/theme/theme.dart';
1920

2021
import 'shell.dart';
2122

23+
/// Navigator used by explicit user confirmations, never by local recovery itself.
24+
final GlobalKey<NavigatorState> appNavigatorKey = GlobalKey<NavigatorState>();
25+
2226
/// The PyBLE root widget: a `MaterialApp` hosting the responsive shell.
2327
class PybleApp extends StatelessWidget {
2428
const PybleApp({super.key});
2529

2630
@override
2731
Widget build(BuildContext context) {
2832
return MaterialApp(
33+
navigatorKey: appNavigatorKey,
2934
// The app wordmark is a brand proper noun, sourced from the ARB (rendered
3035
// verbatim) so the OS task-switcher title follows the localization path.
3136
onGenerateTitle: (BuildContext context) =>
@@ -40,7 +45,7 @@ class PybleApp extends StatelessWidget {
4045
highContrastDarkTheme: PybleTheme.highContrastDark,
4146
themeMode: ThemeMode.system,
4247
debugShowCheckedModeBanner: false,
43-
home: const PybleShell(),
48+
home: const RecoveryGate(child: PybleShell()),
4449
);
4550
}
4651
}

‎app/lib/app/shell.dart‎

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
1919

2020
import 'package:pyble/console/console.dart';
2121
import 'package:pyble/editor/editor.dart';
22+
import 'package:pyble/editor/board_target_guard.dart';
23+
import 'package:pyble/editor/draft_recovery.dart';
24+
import 'package:pyble/editor/recovery_gate.dart';
2225
import 'package:pyble/localization/localization.dart';
2326
import 'package:pyble/pble/pble.dart';
2427
import 'package:pyble/theme/theme.dart';
@@ -149,18 +152,49 @@ class PybleShell extends ConsumerWidget {
149152
/// Pin-reference pane, or console strip, none of which means anything without a
150153
/// board. The toolbar's connection action is an inert Connect slot here (the
151154
/// live scan/connect flow is the surface below); Run/Stop are disabled.
152-
class _DisconnectedShell extends StatelessWidget {
155+
class _DisconnectedShell extends ConsumerStatefulWidget {
153156
const _DisconnectedShell();
154157

158+
@override
159+
ConsumerState<_DisconnectedShell> createState() => _DisconnectedShellState();
160+
}
161+
162+
class _DisconnectedShellState extends ConsumerState<_DisconnectedShell> {
163+
bool _showRecovery = true;
164+
155165
@override
156166
Widget build(BuildContext context) {
157-
return const Scaffold(
167+
final DraftRecoveryState recovery = ref.watch(draftRecoveryProvider);
168+
final bool recovered = ref.watch(editorRecoveredDraftProvider);
169+
final EditorDocument document = ref.watch(editorDocumentProvider);
170+
final bool previewAvailable =
171+
recovery.enabled && (recovered || document.content.isNotEmpty);
172+
final AppLocalizations l10n = AppLocalizations.of(context);
173+
return Scaffold(
158174
body: SafeArea(
159175
child: Column(
160176
children: <Widget>[
161-
_Toolbar(),
162-
Divider(height: 1),
163-
Expanded(child: ConnectPage()),
177+
const _Toolbar(),
178+
const Divider(height: 1),
179+
if (previewAvailable) ...<Widget>[
180+
TextButton.icon(
181+
onPressed: () => setState(() => _showRecovery = !_showRecovery),
182+
icon: Icon(
183+
_showRecovery ? Icons.bluetooth : Icons.description_outlined,
184+
),
185+
label: Text(
186+
_showRecovery ? l10n.recoveryConnect : l10n.recoveryOpen,
187+
),
188+
),
189+
const RecoveryStatusBar(),
190+
],
191+
Expanded(
192+
child: previewAvailable && _showRecovery
193+
? CurrentDraftPreview(
194+
onContinue: () => setState(() => _showRecovery = false),
195+
)
196+
: const ConnectPage(),
197+
),
164198
],
165199
),
166200
),
@@ -509,13 +543,15 @@ class _Toolbar extends ConsumerWidget {
509543
} on EBusy {
510544
// EBusy is never swallowed: tell the user a program is already running.
511545
messenger.showSnackBar(SnackBar(content: Text(l10n.runBusyMessage)));
546+
} on BoardActionCancelled {
547+
return;
512548
} catch (e) {
513549
// NOTHING else was surfaced before: every other failure (a typed ERange
514550
// for an over-long buffer, a link timeout, a torn-down connection)
515551
// became an unhandled async error, so pressing Run did LITERALLY nothing
516552
// visible. FR-RUN-3 requires the failure to be honest.
517553
messenger.showSnackBar(
518-
SnackBar(content: Text(l10n.runFailed(_reasonOf(e)))),
554+
SnackBar(content: Text(l10n.runFailed(_reasonOf(e, l10n)))),
519555
);
520556
}
521557
}
@@ -531,15 +567,19 @@ class _Toolbar extends ConsumerWidget {
531567
await action();
532568
} catch (e) {
533569
messenger.showSnackBar(
534-
SnackBar(content: Text(l10n.runFailed(_reasonOf(e)))),
570+
SnackBar(content: Text(l10n.runFailed(_reasonOf(e, l10n)))),
535571
);
536572
}
537573
}
538574

539575
/// The technical reason token for [e]. A typed PBLE/1 §8 status name is a
540576
/// technical identifier rendered verbatim (FR-I18N-4).
541-
static String _reasonOf(Object e) =>
542-
e is PbleException ? e.runtimeType.toString() : e.toString();
577+
static String _reasonOf(Object e, AppLocalizations l10n) => switch (e) {
578+
ProgramSourceSessionChanged(:final sourcePath) =>
579+
l10n.runSourceSessionChanged(sourcePath),
580+
PbleException() => e.runtimeType.toString(),
581+
_ => e.toString(),
582+
};
543583

544584
static void _openAbout(BuildContext context) {
545585
Navigator.of(context).push<void>(

‎app/lib/editor/editor_view.dart‎

Lines changed: 77 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ import 'package:pyble/pble/pble.dart';
2222
import 'package:pyble/theme/theme.dart';
2323

2424
import 'editor_document.dart';
25+
import 'board_target_guard.dart';
26+
import 'draft_recovery.dart';
27+
import 'recovery_gate.dart';
28+
import 'recovery_panel.dart';
2529
import 'editor_display_settings.dart';
2630
import 'editor_surface.dart';
2731
import 'editor_surface_factory.dart';
@@ -103,6 +107,7 @@ class _EditorViewState extends ConsumerState<EditorView> {
103107
// present, and it fixes on tap — the buffer is never rewritten behind
104108
// the user's back.
105109
const _SmartPunctuationBanner(),
110+
const RecoveryStatusBar(),
106111
// The editable code well — a recessed monospace surface (§1.4).
107112
Expanded(
108113
child: surfaceBuilder(
@@ -267,6 +272,8 @@ class _EditorHeader extends ConsumerWidget {
267272
try {
268273
final String path = await ref.read(saveControllerProvider).save();
269274
messenger.showSnackBar(SnackBar(content: Text(l10n.editorSaved(path))));
275+
} on BoardActionCancelled {
276+
return;
270277
} on PbleException catch (e) {
271278
// The typed §8 status name (EBusy / ENoSpc / ECrc …) is a PBLE/1
272279
// technical identifier: rendered verbatim, never localized (FR-I18N-4).
@@ -291,27 +298,77 @@ class _EditorHeader extends ConsumerWidget {
291298
/// no way to get it back (the document is volatile by design).
292299
Future<void> _newDocument(BuildContext context, WidgetRef ref) async {
293300
final AppLocalizations l10n = AppLocalizations.of(context);
294-
if (ref.read(editorDocumentProvider).dirty) {
295-
final bool? discard = await showDialog<bool>(
296-
context: context,
297-
builder: (BuildContext ctx) => AlertDialog(
298-
title: Text(l10n.editorDiscardTitle),
299-
content: Text(l10n.editorDiscardDetail),
300-
actions: <Widget>[
301-
TextButton(
302-
onPressed: () => Navigator.of(ctx).pop(false),
303-
child: Text(l10n.commonCancel),
304-
),
305-
TextButton(
306-
onPressed: () => Navigator.of(ctx).pop(true),
307-
child: Text(l10n.editorDiscardConfirm),
308-
),
309-
],
310-
),
311-
);
312-
if (discard != true) return;
301+
final int epoch = ref.read(editorDocumentEpochProvider);
302+
final EditorDocument document = ref.read(editorDocumentProvider);
303+
final DraftRecoveryState recovery = ref.read(draftRecoveryProvider);
304+
final bool meaningful =
305+
document.content.isNotEmpty ||
306+
document.name != kDefaultDocumentName ||
307+
document.boardPath != null;
308+
final bool needsChoice =
309+
document.dirty ||
310+
(recovery.enabled &&
311+
meaningful &&
312+
recovery.status != DraftRecoveryStatus.saved);
313+
if (needsChoice) {
314+
if (recovery.enabled) {
315+
if (!await confirmRecoveryReplacement(context)) return;
316+
} else {
317+
final bool? discard = await showDialog<bool>(
318+
context: context,
319+
builder: (BuildContext ctx) => AlertDialog(
320+
scrollable: true,
321+
title: Text(l10n.editorDiscardTitle),
322+
content: Text(l10n.editorDiscardDetail),
323+
actions: <Widget>[
324+
TextButton(
325+
onPressed: () => Navigator.of(ctx).pop(false),
326+
child: Text(l10n.commonCancel),
327+
),
328+
TextButton(
329+
onPressed: () => Navigator.of(ctx).pop(true),
330+
child: Text(l10n.editorDiscardConfirm),
331+
),
332+
],
333+
),
334+
);
335+
if (discard != true) return;
336+
}
337+
}
338+
if (!context.mounted) return;
339+
if (ref.read(editorDocumentEpochProvider) != epoch) {
340+
ScaffoldMessenger.of(
341+
context,
342+
).showSnackBar(SnackBar(content: Text(l10n.recoveryChanged)));
343+
return;
344+
}
345+
if (!recovery.enabled) {
346+
ref.read(editorDocumentProvider.notifier).newDocument();
347+
return;
348+
}
349+
final bool replaced = await ref
350+
.read(draftRecoveryProvider.notifier)
351+
.replaceDocument(const EditorDocument.untitled(), expectedEpoch: epoch);
352+
if (!replaced && context.mounted) {
353+
if (ref.read(editorDocumentEpochProvider) != epoch) {
354+
ScaffoldMessenger.of(
355+
context,
356+
).showSnackBar(SnackBar(content: Text(l10n.recoveryChanged)));
357+
return;
358+
}
359+
if (!await confirmVolatileReplacement(context)) return;
360+
if (!context.mounted || ref.read(editorDocumentEpochProvider) != epoch) {
361+
return;
362+
}
363+
ref.read(draftRecoveryProvider.notifier).continueWithoutRecovery();
364+
await ref
365+
.read(draftRecoveryProvider.notifier)
366+
.replaceDocument(
367+
const EditorDocument.untitled(),
368+
expectedEpoch: epoch,
369+
allowVolatile: true,
370+
);
313371
}
314-
ref.read(editorDocumentProvider.notifier).newDocument();
315372
}
316373
}
317374

0 commit comments

Comments
 (0)