Skip to content

Commit b7b0fed

Browse files
committed
[green] add accessible local recovery preview and confirmations
Signed-off-by: Viwat Vchirawongkwin <viwat.v@chula.ac.th>
1 parent 1324fec commit b7b0fed

5 files changed

Lines changed: 934 additions & 105 deletions

File tree

‎app/lib/editor/recovery_panel.dart‎

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
// SPDX-License-Identifier: MIT
2+
// Part of PyBLE (https://pyble.dev) — see /LICENSE.
3+
4+
/// Local-only recovery preview and explicit confirmations. These widgets have
5+
/// no transport, repository or network dependency: callbacks own the decision.
6+
library;
7+
8+
import 'package:flutter/material.dart';
9+
import 'package:flutter/services.dart';
10+
11+
import 'package:pyble/localization/localization.dart';
12+
13+
const Key kRecoveryRestoreKey = ValueKey<String>('recoveryRestore');
14+
const Key kRecoveryDiscardKey = ValueKey<String>('recoveryDiscard');
15+
const Key kRecoveryConfirmDiscardKey = ValueKey<String>(
16+
'recoveryConfirmDiscard',
17+
);
18+
const Key kRecoveryCopyKey = ValueKey<String>('recoveryCopy');
19+
const Key kRecoveryRetryKey = ValueKey<String>('recoveryRetry');
20+
const Key kRecoveryContinueKey = ValueKey<String>('recoveryContinue');
21+
const Key kRecoverySaveKey = ValueKey<String>('recoverySave');
22+
23+
/// A scrollable, selectable source preview. Merely mounting or dismissing it
24+
/// never restores, discards, copies, connects to a board, or runs code.
25+
class RecoveryPanel extends StatelessWidget {
26+
const RecoveryPanel({
27+
super.key,
28+
required this.name,
29+
required this.content,
30+
required this.checkpointTime,
31+
required this.onRestore,
32+
required this.onDiscard,
33+
this.restored = false,
34+
this.busy = false,
35+
this.failure,
36+
this.onContinue,
37+
});
38+
39+
final String name;
40+
final String content;
41+
final DateTime checkpointTime;
42+
final Future<void> Function() onRestore;
43+
final Future<void> Function() onDiscard;
44+
final bool restored;
45+
final bool busy;
46+
final String? failure;
47+
final VoidCallback? onContinue;
48+
49+
@override
50+
Widget build(BuildContext context) {
51+
final AppLocalizations l10n = AppLocalizations.of(context);
52+
final ThemeData theme = Theme.of(context);
53+
final MaterialLocalizations material = MaterialLocalizations.of(context);
54+
final DateTime localTime = checkpointTime.toLocal();
55+
final String when =
56+
'${material.formatMediumDate(localTime)} '
57+
'${material.formatTimeOfDay(TimeOfDay.fromDateTime(localTime))}';
58+
return SafeArea(
59+
child: SingleChildScrollView(
60+
padding: const EdgeInsets.all(20),
61+
child: Column(
62+
crossAxisAlignment: CrossAxisAlignment.stretch,
63+
children: <Widget>[
64+
Semantics(
65+
header: true,
66+
child: Text(
67+
restored ? l10n.recoveryRestoredTitle : l10n.recoveryTitle,
68+
style: theme.textTheme.headlineSmall,
69+
),
70+
),
71+
const SizedBox(height: 12),
72+
Text(l10n.recoveryLocalOnly),
73+
const SizedBox(height: 12),
74+
SelectableText(name, style: theme.textTheme.titleMedium),
75+
Text(l10n.recoveryCheckpointTime(when)),
76+
const SizedBox(height: 12),
77+
Container(
78+
constraints: const BoxConstraints(maxHeight: 240),
79+
decoration: BoxDecoration(
80+
color: theme.colorScheme.surfaceContainerLow,
81+
border: Border.all(color: theme.colorScheme.outlineVariant),
82+
borderRadius: BorderRadius.circular(8),
83+
),
84+
padding: const EdgeInsets.all(12),
85+
child: SingleChildScrollView(
86+
child: SelectableText(
87+
content,
88+
style: theme.textTheme.bodyMedium?.copyWith(
89+
fontFamily: 'monospace',
90+
),
91+
),
92+
),
93+
),
94+
const SizedBox(height: 12),
95+
Text(l10n.recoveryScope),
96+
if (failure != null) ...<Widget>[
97+
const SizedBox(height: 12),
98+
Semantics(liveRegion: true, child: Text(failure!)),
99+
],
100+
const SizedBox(height: 16),
101+
Wrap(
102+
spacing: 12,
103+
runSpacing: 8,
104+
children: <Widget>[
105+
if (!restored)
106+
FilledButton.icon(
107+
key: kRecoveryRestoreKey,
108+
onPressed: busy ? null : onRestore,
109+
icon: const Icon(Icons.restore),
110+
label: Text(l10n.recoveryRestore),
111+
),
112+
OutlinedButton.icon(
113+
key: kRecoveryCopyKey,
114+
onPressed: busy
115+
? null
116+
: () async {
117+
final ScaffoldMessengerState? messenger =
118+
ScaffoldMessenger.maybeOf(context);
119+
await Clipboard.setData(ClipboardData(text: content));
120+
messenger?.showSnackBar(
121+
SnackBar(content: Text(l10n.recoveryCopied)),
122+
);
123+
},
124+
icon: const Icon(Icons.copy_outlined),
125+
label: Text(l10n.recoveryCopy),
126+
),
127+
if (!restored)
128+
TextButton(
129+
key: kRecoveryDiscardKey,
130+
onPressed: busy
131+
? null
132+
: () async {
133+
if (await _confirmDiscard(context))
134+
await onDiscard();
135+
},
136+
child: Text(l10n.recoveryDiscard),
137+
),
138+
if (onContinue != null)
139+
FilledButton(
140+
onPressed: busy ? null : onContinue,
141+
child: Text(l10n.recoveryConnect),
142+
),
143+
],
144+
),
145+
if (busy) ...<Widget>[
146+
const SizedBox(height: 12),
147+
LinearProgressIndicator(semanticsLabel: l10n.recoverySaving),
148+
],
149+
],
150+
),
151+
),
152+
);
153+
}
154+
155+
Future<bool> _confirmDiscard(BuildContext context) async {
156+
final AppLocalizations l10n = AppLocalizations.of(context);
157+
return await showDialog<bool>(
158+
context: context,
159+
builder: (BuildContext ctx) => AlertDialog(
160+
scrollable: true,
161+
title: Text(l10n.recoveryDiscardTitle),
162+
content: Text(l10n.recoveryDiscardDetail),
163+
actions: <Widget>[
164+
TextButton(
165+
onPressed: () => Navigator.of(ctx).pop(false),
166+
child: Text(l10n.commonCancel),
167+
),
168+
TextButton(
169+
key: kRecoveryConfirmDiscardKey,
170+
onPressed: () => Navigator.of(ctx).pop(true),
171+
child: Text(l10n.recoveryDiscard),
172+
),
173+
],
174+
),
175+
) ??
176+
false;
177+
}
178+
}
179+
180+
/// Confirmation for a current-session board action on recovered text. The
181+
/// caller must recheck document, target and attachment after this future.
182+
Future<bool> confirmRecoveryBoardTarget(
183+
BuildContext context, {
184+
required String path,
185+
required bool run,
186+
}) async {
187+
final AppLocalizations l10n = AppLocalizations.of(context);
188+
return await showDialog<bool>(
189+
context: context,
190+
builder: (BuildContext ctx) => AlertDialog(
191+
scrollable: true,
192+
title: Text(l10n.recoveryBoardTargetTitle),
193+
content: Text(
194+
run
195+
? l10n.recoveryBoardTargetRunDetail(path)
196+
: l10n.recoveryBoardTargetSaveDetail(path),
197+
),
198+
actions: <Widget>[
199+
TextButton(
200+
onPressed: () => Navigator.of(ctx).pop(false),
201+
child: Text(l10n.commonCancel),
202+
),
203+
FilledButton(
204+
onPressed: () => Navigator.of(ctx).pop(true),
205+
child: Text(
206+
run
207+
? l10n.recoveryBoardTargetRun
208+
: l10n.recoveryBoardTargetSave,
209+
),
210+
),
211+
],
212+
),
213+
) ??
214+
false;
215+
}
216+
217+
/// Consent to replace the single current draft. Epoch/session fencing belongs
218+
/// to the caller because an editor can change while a dialog is open.
219+
Future<bool> confirmRecoveryReplacement(BuildContext context) async {
220+
final AppLocalizations l10n = AppLocalizations.of(context);
221+
return await showDialog<bool>(
222+
context: context,
223+
builder: (BuildContext ctx) => AlertDialog(
224+
scrollable: true,
225+
title: Text(l10n.recoveryReplaceTitle),
226+
content: Text(l10n.recoveryReplaceDetail),
227+
actions: <Widget>[
228+
TextButton(
229+
onPressed: () => Navigator.of(ctx).pop(false),
230+
child: Text(l10n.commonCancel),
231+
),
232+
FilledButton(
233+
onPressed: () => Navigator.of(ctx).pop(true),
234+
child: Text(l10n.recoveryReplace),
235+
),
236+
],
237+
),
238+
) ??
239+
false;
240+
}

0 commit comments

Comments
 (0)