feat: back is greyed while an import runs, so nothing cuts it off

A sheet can be busy (SheetScaffold.busy): its close button is off and
greyed, and back does nothing until it ends. The Anki import's sheet is
busy while it imports; before, its close button looked as always and
did nothing.

A studyset's screen, while it imports a PDF or a path, greys its back
button and the path's steps and ignores back: leaving would close the
lists' blocs and cut the import off. Importing a path now says so in its
state, as a PDF's did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-11 14:10:43 +02:00
commit 2283999ca9
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
9 changed files with 320 additions and 197 deletions

View file

@ -120,6 +120,12 @@ class const SheetScaffold({
/// can be acted on (an Undo, say); those of the screens below would sit /// can be acted on (an Undo, say); those of the screens below would sit
/// behind its barrier. The sheet then always takes its largest height. /// behind its barrier. The sheet then always takes its largest height.
final bool hasSnackBars = false, final bool hasSnackBars = false,
/// Whether something runs that mustn't be cut off, as an import: the
/// close button is off, greyed, and back does nothing until it ends. A
/// sheet that can be busy is shown with `swipeable` false, as a bottom
/// sheet swiped away closes whatever it holds says.
final bool busy = false,
super.key, super.key,
}) extends StatelessWidget { }) extends StatelessWidget {
/// The widest a sheet grows as a dialog. /// The widest a sheet grows as a dialog.
@ -154,7 +160,15 @@ class const SheetScaffold({
: MainAxisSize.max, : MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
inset(_Header(title: title, action: action, onClose: onClose)), inset(
_Header(
title: title,
action: action,
onClose: busy
? null
: onClose ?? () => Navigator.of(context).maybePop(),
),
),
// Loose, so a short body keeps the sheet short; tight at a fixed // Loose, so a short body keeps the sheet short; tight at a fixed
// height, so the footer sits at the bottom. // height, so the footer sits at the bottom.
if (!isDialog && heightFactor != null) if (!isDialog && heightFactor != null)
@ -176,16 +190,19 @@ class const SheetScaffold({
], ],
], ],
); );
final content = hasSnackBars final content = PopScope(
? ScaffoldMessenger( canPop: !busy,
child: Scaffold( child: hasSnackBars
backgroundColor: Colors.transparent, ? ScaffoldMessenger(
// The sheet keeps clear of the keyboard itself. child: Scaffold(
resizeToAvoidBottomInset: false, backgroundColor: Colors.transparent,
body: column, // The sheet keeps clear of the keyboard itself.
), resizeToAvoidBottomInset: false,
) body: column,
: column; ),
)
: column,
);
if (isDialog) { if (isDialog) {
// The dialog keeps clear of the window's edges and the keyboard // The dialog keeps clear of the window's edges and the keyboard
// itself; insetting here as well would count them twice. // itself; insetting here as well would count them twice.
@ -238,9 +255,10 @@ class const SheetScaffold({
} }
} }
/// The sheet's title and its close button. The button is there beside /// The sheet's title and its close button, off without [onClose]. The
/// swiping the sheet away, which fails with wet fingers, gloves or a sheet /// button is there beside swiping the sheet away, which fails with wet
/// taller than the window, and a dialog can't be swiped at all. /// fingers, gloves or a sheet taller than the window, and a dialog can't be
/// swiped at all.
class const _Header({ class const _Header({
required final String title, required final String title,
required final Widget? action, required final Widget? action,
@ -261,7 +279,7 @@ class const _Header({
IconButton( IconButton(
tooltip: AppLocalizations.of(context).commonClose, tooltip: AppLocalizations.of(context).commonClose,
icon: const Icon(LucideIcons.x), icon: const Icon(LucideIcons.x),
onPressed: onClose ?? () => Navigator.of(context).maybePop(), onPressed: onClose,
), ),
], ],
); );

View file

@ -93,52 +93,48 @@ class const _AnkiImport() extends StatelessWidget {
) => (summary, options), ) => (summary, options),
_ => (null, null), _ => (null, null),
}; };
return PopScope( return SheetScaffold(
canPop: !running, title: summary?.hasSubdecks ?? false
child: SheetScaffold( ? l10n.ankiImportOptionsTitle
title: summary?.hasSubdecks ?? false : l10n.ankiImportTitle,
? l10n.ankiImportOptionsTitle // Closing would leave half the deck imported.
: l10n.ankiImportTitle, busy: running,
onClose: running ? () {} : null, footer: summary == null
footer: summary == null ? null
? null : FilledButton(
: FilledButton( onPressed: running
onPressed: running ? null
? null : () => context.read<AnkiImportBloc>().add(
: () => context.read<AnkiImportBloc>().add( const AnkiImportEventConfirmed(),
const AnkiImportEventConfirmed(), ),
), child: Text(l10n.ankiImportConfirm),
child: Text(l10n.ankiImportConfirm),
),
body: switch (state) {
AnkiImportStateReading() || AnkiImportStateDone() => Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Column(
children: [
const AppLoadingIndicator(),
const SizedBox(height: 12),
Text(l10n.ankiImportReading),
],
), ),
body: switch (state) {
AnkiImportStateReading() || AnkiImportStateDone() => Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Column(
children: [
const AppLoadingIndicator(),
const SizedBox(height: 12),
Text(l10n.ankiImportReading),
],
), ),
AnkiImportStateFailed(:final problem) => Padding( ),
padding: const EdgeInsets.symmetric(vertical: 16), AnkiImportStateFailed(:final problem) => Padding(
child: Text(switch (problem) { padding: const EdgeInsets.symmetric(vertical: 16),
AnkiPackageProblem.notAPackage => l10n.ankiImportNotAPackage, child: Text(switch (problem) {
AnkiPackageProblem.newerFormat => l10n.ankiImportNewerFormat, AnkiPackageProblem.notAPackage => l10n.ankiImportNotAPackage,
AnkiPackageProblem.unreadable => l10n.ankiImportUnreadable, AnkiPackageProblem.newerFormat => l10n.ankiImportNewerFormat,
null => l10n.ankiImportBrokeOff, AnkiPackageProblem.unreadable => l10n.ankiImportUnreadable,
}), null => l10n.ankiImportBrokeOff,
), }),
_ => _Options( ),
summary: summary!, _ => _Options(
options: options!, summary: summary!,
progress: state is AnkiImportStateRunning options: options!,
? state.progress progress: state is AnkiImportStateRunning ? state.progress : null,
: null, ),
), },
},
),
); );
}, },
); );

View file

@ -30,6 +30,7 @@ class PathListBloc(
emit(state.copyWith(created: id)); emit(state.copyWith(created: id));
}); });
on<PathListEventImportRequested>((event, emit) async { on<PathListEventImportRequested>((event, emit) async {
emit(state.copyWith(importing: true));
try { try {
final parsed = parsePathImport(event.source); final parsed = parsePathImport(event.source);
final imported = await PathImporter( final imported = await PathImporter(
@ -37,6 +38,7 @@ class PathListBloc(
).import(_studysetId, event.source); ).import(_studysetId, event.source);
emit( emit(
state.copyWith( state.copyWith(
importing: false,
importResult: ( importResult: (
title: parsed.content.title, title: parsed.content.title,
steps: imported.steps, steps: imported.steps,
@ -48,6 +50,7 @@ class PathListBloc(
} on PathImportException catch (error) { } on PathImportException catch (error) {
emit( emit(
state.copyWith( state.copyWith(
importing: false,
importResult: ( importResult: (
title: '', title: '',
steps: 0, steps: 0,
@ -207,6 +210,9 @@ final class const PathListState({
/// null before any. /// null before any.
final ImportResult? importResult, final ImportResult? importResult,
/// Whether a path is being imported.
final bool importing = false,
/// Whether the last read failed; the next change reads again. /// Whether the last read failed; the next change reads again.
final bool failed = false, final bool failed = false,
}) with Equatable { }) with Equatable {
@ -228,6 +234,7 @@ final class const PathListState({
bool? completionKnown, bool? completionKnown,
String? created, String? created,
ImportResult? importResult, ImportResult? importResult,
bool? importing,
bool? failed, bool? failed,
}) => PathListState( }) => PathListState(
loaded: loaded ?? this.loaded, loaded: loaded ?? this.loaded,
@ -237,6 +244,7 @@ final class const PathListState({
completionKnown: completionKnown ?? this.completionKnown, completionKnown: completionKnown ?? this.completionKnown,
created: created ?? this.created, created: created ?? this.created,
importResult: importResult ?? this.importResult, importResult: importResult ?? this.importResult,
importing: importing ?? this.importing,
failed: failed ?? this.failed, failed: failed ?? this.failed,
); );
@ -249,6 +257,7 @@ final class const PathListState({
completionKnown, completionKnown,
created, created,
importResult, importResult,
importing,
failed, failed,
]; ];
} }

View file

@ -28,6 +28,10 @@ class const StudysetBreadcrumb({
/// What ends the line, after the path. /// What ends the line, after the path.
final Widget? trailing, final Widget? trailing,
/// Whether the steps lead anywhere: not while something runs that leaving
/// would cut off, as an import.
final bool enabled = true,
super.key, super.key,
}) extends StatelessWidget { }) extends StatelessWidget {
/// The room around a separator, on each side. /// The room around a separator, on each side.
@ -47,9 +51,15 @@ class const StudysetBreadcrumb({
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
); );
final steps = <_Step>[ final steps = <_Step>[
_Step(l10n.libraryTitle, () => goUpTo(context, null, all)), _Step(
l10n.libraryTitle,
enabled ? () => goUpTo(context, null, all) : null,
),
for (final ancestor in ancestors) for (final ancestor in ancestors)
_Step(ancestor.content.title, () => goUpTo(context, ancestor.id, all)), _Step(
ancestor.content.title,
enabled ? () => goUpTo(context, ancestor.id, all) : null,
),
]; ];
Widget separator() => Padding( Widget separator() => Padding(
padding: const EdgeInsets.symmetric(horizontal: _separatorPadding), padding: const EdgeInsets.symmetric(horizontal: _separatorPadding),
@ -101,7 +111,9 @@ class const StudysetBreadcrumb({
math.min(folded, steps.length), math.min(folded, steps.length),
)) ...[ )) ...[
MouseRegion( MouseRegion(
cursor: SystemMouseCursors.click, cursor: step.onTap == null
? MouseCursor.defer
: SystemMouseCursors.click,
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: step.onTap, onTap: step.onTap,
@ -140,8 +152,8 @@ class const StudysetBreadcrumb({
} }
} }
/// A step of the way, and where it leads. /// A step of the way, and where it leads; nowhere while null.
class const _Step(final String label, final VoidCallback onTap); class const _Step(final String label, final VoidCallback? onTap);
/// The steps that didn't fit, behind a "…" that turns primary under the /// The steps that didn't fit, behind a "…" that turns primary under the
/// pointer, nearest first in its menu; a square target as high as a step. /// pointer, nearest first in its menu; a square target as high as a step.

View file

@ -76,6 +76,10 @@ class const StudysetBackButton({
/// All the person's studysets, for [goUpTo]. /// All the person's studysets, for [goUpTo].
required final List<Studyset> all, required final List<Studyset> all,
/// Whether it leads anywhere: not while something runs that leaving would
/// cut off, as an import; it is greyed then.
final bool enabled = true,
super.key, super.key,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
@ -94,7 +98,7 @@ class const StudysetBackButton({
), ),
], ],
builder: (context, menu, _) => GestureDetector( builder: (context, menu, _) => GestureDetector(
onLongPress: menu.open, onLongPress: enabled ? menu.open : null,
// The name shows under the pointer, but a long press is the list's: // The name shows under the pointer, but a long press is the list's:
// a tooltip would take it on a touch screen. // a tooltip would take it on a touch screen.
child: Tooltip( child: Tooltip(
@ -102,7 +106,7 @@ class const StudysetBackButton({
triggerMode: TooltipTriggerMode.manual, triggerMode: TooltipTriggerMode.manual,
child: IconButton( child: IconButton(
icon: const BackButtonIcon(), icon: const BackButtonIcon(),
onPressed: () => Navigator.maybePop(context), onPressed: enabled ? () => Navigator.maybePop(context) : null,
), ),
), ),
), ),

View file

@ -339,150 +339,159 @@ class _StudysetViewState() extends State<_StudysetView> {
final theme = Theme.of(context); final theme = Theme.of(context);
final wide = context.windowSize.isAtLeastExpanded; final wide = context.windowSize.isAtLeastExpanded;
final canGoBack = context.canPop(); final canGoBack = context.canPop();
return Scaffold( // Leaving would close the lists' blocs, and cut off what they import.
backgroundColor: Colors.transparent, final busy =
appBar: AppBar( context.select<PdfListBloc, bool>((bloc) => bloc.state.importing) ||
context.select<PathListBloc, bool>((bloc) => bloc.state.importing);
return PopScope(
canPop: !busy,
child: Scaffold(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
systemOverlayStyle: TopGradient.overlayStyle(context), appBar: AppBar(
leading: canGoBack backgroundColor: Colors.transparent,
? StudysetBackButton( systemOverlayStyle: TopGradient.overlayStyle(context),
ancestors: ancestorsOf(studyset.id, all), leading: canGoBack
all: all, ? StudysetBackButton(
) ancestors: ancestorsOf(studyset.id, all),
: null, all: all,
title: TitleWithKicker( enabled: !busy,
title: studyset.content.title, )
kicker: studyset.content.secondaryTitle, : null,
titleMaxLines: 1, title: TitleWithKicker(
titleStyle: title: studyset.content.title,
theme.appBarTheme.titleTextStyle ?? theme.textTheme.titleLarge, kicker: studyset.content.secondaryTitle,
), titleMaxLines: 1,
actions: [ titleStyle:
if (wide) ...[ theme.appBarTheme.titleTextStyle ?? theme.textTheme.titleLarge,
Padding( ),
padding: const EdgeInsets.symmetric(horizontal: 8), actions: [
child: SizedBox( if (wide) ...[
height: 48, Padding(
child: _StudyButton(studyset: studyset, inAppBar: true), padding: const EdgeInsets.symmetric(horizontal: 8),
child: SizedBox(
height: 48,
child: _StudyButton(studyset: studyset, inAppBar: true),
),
), ),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: _AddMaterial(
studysetId: studyset.id,
controller: _addMaterial,
builder: (context, menu) => roomy
? FilledButton.tonalIcon(
style: FilledButton.styleFrom(
minimumSize: const Size(0, 48),
),
onPressed: () =>
menu.isOpen ? menu.close() : menu.open(),
icon: const Icon(LucideIcons.plus),
label: Text(
AppLocalizations.of(context).studysetAddMaterial,
),
)
: IconButton.filledTonal(
tooltip: AppLocalizations.of(context)
.studysetAddMaterial,
onPressed: () =>
menu.isOpen ? menu.close() : menu.open(),
icon: const Icon(LucideIcons.plus),
),
),
),
],
Padding(
padding: wide
? const EdgeInsets.symmetric(horizontal: 8)
: EdgeInsets.zero,
child: ShareButton(studyset: studyset, all: all),
), ),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: _AddMaterial( child: _Options(studyset: studyset, all: all),
studysetId: studyset.id,
controller: _addMaterial,
builder: (context, menu) => roomy
? FilledButton.tonalIcon(
style: FilledButton.styleFrom(
minimumSize: const Size(0, 48),
),
onPressed: () =>
menu.isOpen ? menu.close() : menu.open(),
icon: const Icon(LucideIcons.plus),
label: Text(
AppLocalizations.of(context).studysetAddMaterial,
),
)
: IconButton.filledTonal(
tooltip: AppLocalizations.of(context)
.studysetAddMaterial,
onPressed: () =>
menu.isOpen ? menu.close() : menu.open(),
icon: const Icon(LucideIcons.plus),
),
),
), ),
], ],
Padding( ),
padding: wide floatingActionButton: fab,
? const EdgeInsets.symmetric(horizontal: 8) // Above the licence line, which it would cover otherwise.
: EdgeInsets.zero, floatingActionButtonLocation: wide
child: ShareButton(studyset: studyset, all: all), ? FloatingActionButtonLocation.endFloat
), : LicenseFooter.shows(studyset.content)
Padding( ? const _RaisedCenterFloat(_licenceClearance)
padding: const EdgeInsets.symmetric(horizontal: 8), : FloatingActionButtonLocation.centerFloat,
child: _Options(studyset: studyset, all: all), body: Column(
), crossAxisAlignment: CrossAxisAlignment.stretch,
], children: [
), StudysetBreadcrumb(
floatingActionButton: fab, ancestors: ancestorsOf(studyset.id, all),
// Above the licence line, which it would cover otherwise. current: studyset,
floatingActionButtonLocation: wide all: all,
? FloatingActionButtonLocation.endFloat enabled: !busy,
: LicenseFooter.shows(studyset.content) trailing: wide
? const _RaisedCenterFloat(_licenceClearance) ? null
: FloatingActionButtonLocation.centerFloat, : _TopicsButton(
body: Column( count: context.select<StudysetListBloc, int>(
crossAxisAlignment: CrossAxisAlignment.stretch, (bloc) => switch (bloc.state) {
children: [ StudysetListStateLoaded(:final studysets) =>
StudysetBreadcrumb( studysets.length,
ancestors: ancestorsOf(studyset.id, all), _ => 0,
current: studyset, },
all: all, ),
trailing: wide onPressed: () => _showTopics(context),
? null
: _TopicsButton(
count: context.select<StudysetListBloc, int>(
(bloc) => switch (bloc.state) {
StudysetListStateLoaded(:final studysets) =>
studysets.length,
_ => 0,
},
), ),
onPressed: () => _showTopics(context),
),
),
CopyUpdateBanner(studyset: studyset),
if (!wide && description != null)
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: SectionCard(child: description),
), ),
Expanded( CopyUpdateBanner(studyset: studyset),
child: wide if (!wide && description != null)
? Padding( Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 12), padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: Row( child: SectionCard(child: description),
crossAxisAlignment: CrossAxisAlignment.stretch, ),
children: [ Expanded(
SizedBox( child: wide
width: 420, ? Padding(
child: Column( padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
crossAxisAlignment: CrossAxisAlignment.stretch, child: Row(
children: [ crossAxisAlignment: CrossAxisAlignment.stretch,
if (description != null) ...[ children: [
SectionCard(child: description), SizedBox(
const SizedBox(height: 12), width: 420,
], child: Column(
Expanded( crossAxisAlignment: CrossAxisAlignment.stretch,
child: SectionCard( children: [
padding: const EdgeInsets.only(bottom: 12), if (description != null) ...[
child: rail, SectionCard(child: description),
const SizedBox(height: 12),
],
Expanded(
child: SectionCard(
padding: const EdgeInsets.only(bottom: 12),
child: rail,
),
), ),
), ],
], ),
), ),
), const SizedBox(width: 12),
const SizedBox(width: 12), Expanded(
Expanded( child: SectionCard(
child: SectionCard( padding: EdgeInsets.zero,
padding: EdgeInsets.zero, child: content,
child: content, ),
), ),
), ],
], ),
)
: Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
child: SectionCard(
padding: EdgeInsets.zero,
child: content,
),
), ),
) ),
: Padding( LicenseFooter(content: studyset.content),
padding: const EdgeInsets.fromLTRB(12, 4, 12, 12), ],
child: SectionCard( ),
padding: EdgeInsets.zero,
child: content,
),
),
),
LicenseFooter(content: studyset.content),
],
), ),
); );
} }

View file

@ -12,6 +12,7 @@ void main() {
required double width, required double width,
Widget body = const Text('Body'), Widget body = const Text('Body'),
double statusBar = 0, double statusBar = 0,
bool busy = false,
}) async { }) async {
tester.view.physicalSize = Size(width, 900); tester.view.physicalSize = Size(width, 900);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
@ -27,8 +28,10 @@ void main() {
builder: (context) => TextButton( builder: (context) => TextButton(
onPressed: () => showAdaptiveSheet<void>( onPressed: () => showAdaptiveSheet<void>(
context, context,
swipeable: !busy,
builder: (context) => SheetScaffold( builder: (context) => SheetScaffold(
title: 'New studyset', title: 'New studyset',
busy: busy,
body: body, body: body,
footer: FilledButton( footer: FilledButton(
onPressed: () {}, onPressed: () {},
@ -105,6 +108,27 @@ void main() {
expect(find.text('New studyset'), findsNothing); expect(find.text('New studyset'), findsNothing);
}); });
testWidgets('while busy, its close button is off and back keeps it open', (
tester,
) async {
await open(tester, width: 400, busy: true);
expect(
tester
.widget<IconButton>(
find.ancestor(
of: find.byTooltip('Close'),
matching: find.byType(IconButton),
),
)
.onPressed,
isNull,
);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('New studyset'), findsOneWidget);
});
group('a body longer than the sheet', () { group('a body longer than the sheet', () {
final tall = Column( final tall = Column(
children: [for (var i = 0; i < 40; i++) const Text('Line')], children: [for (var i = 0; i < 40; i++) const Text('Line')],

View file

@ -106,7 +106,9 @@ void main() {
'{"type": "image", "src": "https://example.org/x.png"}]}]}}', '{"type": "image", "src": "https://example.org/x.png"}]}]}}',
), ),
); );
await until((s) => s.importing);
final imported = await until((s) => s.importResult != null); final imported = await until((s) => s.importResult != null);
expect(imported.importing, isFalse);
expect(imported.importResult, ( expect(imported.importResult, (
title: 'Particles', title: 'Particles',
steps: 1, steps: 1,

View file

@ -0,0 +1,49 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_navigation.dart';
import 'package:material_ui/material_ui.dart';
void main() {
Future<void> show(WidgetTester tester, {required bool enabled}) =>
tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: StudysetBackButton(
ancestors: const [],
all: const [],
enabled: enabled,
),
),
),
);
IconButton button(WidgetTester tester) => tester.widget<IconButton>(
find.descendant(
of: find.byType(StudysetBackButton),
matching: find.byType(IconButton),
),
);
testWidgets('while something runs here, back is greyed and its levels '
'stay closed', (tester) async {
await show(tester, enabled: false);
expect(button(tester).onPressed, isNull);
await tester.longPress(find.byType(StudysetBackButton));
await tester.pumpAndSettle();
expect(find.text('Library'), findsNothing);
});
testWidgets('otherwise back works and holding it lists the levels', (
tester,
) async {
await show(tester, enabled: true);
expect(button(tester).onPressed, isNotNull);
await tester.longPress(find.byType(StudysetBackButton));
await tester.pumpAndSettle();
expect(find.text('Library'), findsOneWidget);
});
}