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
/// behind its barrier. The sheet then always takes its largest height.
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,
}) extends StatelessWidget {
/// The widest a sheet grows as a dialog.
@ -154,7 +160,15 @@ class const SheetScaffold({
: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
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
// height, so the footer sits at the bottom.
if (!isDialog && heightFactor != null)
@ -176,16 +190,19 @@ class const SheetScaffold({
],
],
);
final content = hasSnackBars
? ScaffoldMessenger(
child: Scaffold(
backgroundColor: Colors.transparent,
// The sheet keeps clear of the keyboard itself.
resizeToAvoidBottomInset: false,
body: column,
),
)
: column;
final content = PopScope(
canPop: !busy,
child: hasSnackBars
? ScaffoldMessenger(
child: Scaffold(
backgroundColor: Colors.transparent,
// The sheet keeps clear of the keyboard itself.
resizeToAvoidBottomInset: false,
body: column,
),
)
: column,
);
if (isDialog) {
// The dialog keeps clear of the window's edges and the keyboard
// 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
/// swiping the sheet away, which fails with wet fingers, gloves or a sheet
/// taller than the window, and a dialog can't be swiped at all.
/// The sheet's title and its close button, off without [onClose]. The
/// button is there beside swiping the sheet away, which fails with wet
/// fingers, gloves or a sheet taller than the window, and a dialog can't be
/// swiped at all.
class const _Header({
required final String title,
required final Widget? action,
@ -261,7 +279,7 @@ class const _Header({
IconButton(
tooltip: AppLocalizations.of(context).commonClose,
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),
_ => (null, null),
};
return PopScope(
canPop: !running,
child: SheetScaffold(
title: summary?.hasSubdecks ?? false
? l10n.ankiImportOptionsTitle
: l10n.ankiImportTitle,
onClose: running ? () {} : null,
footer: summary == null
? null
: FilledButton(
onPressed: running
? null
: () => context.read<AnkiImportBloc>().add(
const AnkiImportEventConfirmed(),
),
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),
],
return SheetScaffold(
title: summary?.hasSubdecks ?? false
? l10n.ankiImportOptionsTitle
: l10n.ankiImportTitle,
// Closing would leave half the deck imported.
busy: running,
footer: summary == null
? null
: FilledButton(
onPressed: running
? null
: () => context.read<AnkiImportBloc>().add(
const AnkiImportEventConfirmed(),
),
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),
],
),
AnkiImportStateFailed(:final problem) => Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text(switch (problem) {
AnkiPackageProblem.notAPackage => l10n.ankiImportNotAPackage,
AnkiPackageProblem.newerFormat => l10n.ankiImportNewerFormat,
AnkiPackageProblem.unreadable => l10n.ankiImportUnreadable,
null => l10n.ankiImportBrokeOff,
}),
),
_ => _Options(
summary: summary!,
options: options!,
progress: state is AnkiImportStateRunning
? state.progress
: null,
),
},
),
),
AnkiImportStateFailed(:final problem) => Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text(switch (problem) {
AnkiPackageProblem.notAPackage => l10n.ankiImportNotAPackage,
AnkiPackageProblem.newerFormat => l10n.ankiImportNewerFormat,
AnkiPackageProblem.unreadable => l10n.ankiImportUnreadable,
null => l10n.ankiImportBrokeOff,
}),
),
_ => _Options(
summary: summary!,
options: options!,
progress: state is AnkiImportStateRunning ? state.progress : null,
),
},
);
},
);

View file

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

View file

@ -28,6 +28,10 @@ class const StudysetBreadcrumb({
/// What ends the line, after the path.
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,
}) extends StatelessWidget {
/// The room around a separator, on each side.
@ -47,9 +51,15 @@ class const StudysetBreadcrumb({
fontWeight: FontWeight.w600,
);
final steps = <_Step>[
_Step(l10n.libraryTitle, () => goUpTo(context, null, all)),
_Step(
l10n.libraryTitle,
enabled ? () => goUpTo(context, null, all) : null,
),
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(
padding: const EdgeInsets.symmetric(horizontal: _separatorPadding),
@ -101,7 +111,9 @@ class const StudysetBreadcrumb({
math.min(folded, steps.length),
)) ...[
MouseRegion(
cursor: SystemMouseCursors.click,
cursor: step.onTap == null
? MouseCursor.defer
: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: step.onTap,
@ -140,8 +152,8 @@ class const StudysetBreadcrumb({
}
}
/// A step of the way, and where it leads.
class const _Step(final String label, final VoidCallback onTap);
/// A step of the way, and where it leads; nowhere while null.
class const _Step(final String label, final VoidCallback? onTap);
/// 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.

View file

@ -76,6 +76,10 @@ class const StudysetBackButton({
/// All the person's studysets, for [goUpTo].
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,
}) extends StatelessWidget {
@override
@ -94,7 +98,7 @@ class const StudysetBackButton({
),
],
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:
// a tooltip would take it on a touch screen.
child: Tooltip(
@ -102,7 +106,7 @@ class const StudysetBackButton({
triggerMode: TooltipTriggerMode.manual,
child: IconButton(
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 wide = context.windowSize.isAtLeastExpanded;
final canGoBack = context.canPop();
return Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
// Leaving would close the lists' blocs, and cut off what they import.
final busy =
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,
systemOverlayStyle: TopGradient.overlayStyle(context),
leading: canGoBack
? StudysetBackButton(
ancestors: ancestorsOf(studyset.id, all),
all: all,
)
: null,
title: TitleWithKicker(
title: studyset.content.title,
kicker: studyset.content.secondaryTitle,
titleMaxLines: 1,
titleStyle:
theme.appBarTheme.titleTextStyle ?? theme.textTheme.titleLarge,
),
actions: [
if (wide) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: SizedBox(
height: 48,
child: _StudyButton(studyset: studyset, inAppBar: true),
appBar: AppBar(
backgroundColor: Colors.transparent,
systemOverlayStyle: TopGradient.overlayStyle(context),
leading: canGoBack
? StudysetBackButton(
ancestors: ancestorsOf(studyset.id, all),
all: all,
enabled: !busy,
)
: null,
title: TitleWithKicker(
title: studyset.content.title,
kicker: studyset.content.secondaryTitle,
titleMaxLines: 1,
titleStyle:
theme.appBarTheme.titleTextStyle ?? theme.textTheme.titleLarge,
),
actions: [
if (wide) ...[
Padding(
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: 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),
),
),
child: _Options(studyset: studyset, all: all),
),
],
Padding(
padding: wide
? const EdgeInsets.symmetric(horizontal: 8)
: EdgeInsets.zero,
child: ShareButton(studyset: studyset, all: all),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: _Options(studyset: studyset, all: all),
),
],
),
floatingActionButton: fab,
// Above the licence line, which it would cover otherwise.
floatingActionButtonLocation: wide
? FloatingActionButtonLocation.endFloat
: LicenseFooter.shows(studyset.content)
? const _RaisedCenterFloat(_licenceClearance)
: FloatingActionButtonLocation.centerFloat,
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
StudysetBreadcrumb(
ancestors: ancestorsOf(studyset.id, all),
current: studyset,
all: all,
trailing: wide
? null
: _TopicsButton(
count: context.select<StudysetListBloc, int>(
(bloc) => switch (bloc.state) {
StudysetListStateLoaded(:final studysets) =>
studysets.length,
_ => 0,
},
),
floatingActionButton: fab,
// Above the licence line, which it would cover otherwise.
floatingActionButtonLocation: wide
? FloatingActionButtonLocation.endFloat
: LicenseFooter.shows(studyset.content)
? const _RaisedCenterFloat(_licenceClearance)
: FloatingActionButtonLocation.centerFloat,
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
StudysetBreadcrumb(
ancestors: ancestorsOf(studyset.id, all),
current: studyset,
all: all,
enabled: !busy,
trailing: wide
? null
: _TopicsButton(
count: context.select<StudysetListBloc, int>(
(bloc) => switch (bloc.state) {
StudysetListStateLoaded(:final studysets) =>
studysets.length,
_ => 0,
},
),
onPressed: () => _showTopics(context),
),
onPressed: () => _showTopics(context),
),
),
CopyUpdateBanner(studyset: studyset),
if (!wide && description != null)
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: SectionCard(child: description),
),
Expanded(
child: wide
? Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: 420,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (description != null) ...[
SectionCard(child: description),
const SizedBox(height: 12),
],
Expanded(
child: SectionCard(
padding: const EdgeInsets.only(bottom: 12),
child: rail,
CopyUpdateBanner(studyset: studyset),
if (!wide && description != null)
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: SectionCard(child: description),
),
Expanded(
child: wide
? Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: 420,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (description != null) ...[
SectionCard(child: description),
const SizedBox(height: 12),
],
Expanded(
child: SectionCard(
padding: const EdgeInsets.only(bottom: 12),
child: rail,
),
),
),
],
],
),
),
),
const SizedBox(width: 12),
Expanded(
child: SectionCard(
padding: EdgeInsets.zero,
child: content,
const SizedBox(width: 12),
Expanded(
child: SectionCard(
padding: EdgeInsets.zero,
child: content,
),
),
),
],
],
),
)
: Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
child: SectionCard(
padding: EdgeInsets.zero,
child: content,
),
),
)
: Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
child: SectionCard(
padding: EdgeInsets.zero,
child: content,
),
),
),
LicenseFooter(content: studyset.content),
],
),
LicenseFooter(content: studyset.content),
],
),
),
);
}

View file

@ -12,6 +12,7 @@ void main() {
required double width,
Widget body = const Text('Body'),
double statusBar = 0,
bool busy = false,
}) async {
tester.view.physicalSize = Size(width, 900);
tester.view.devicePixelRatio = 1;
@ -27,8 +28,10 @@ void main() {
builder: (context) => TextButton(
onPressed: () => showAdaptiveSheet<void>(
context,
swipeable: !busy,
builder: (context) => SheetScaffold(
title: 'New studyset',
busy: busy,
body: body,
footer: FilledButton(
onPressed: () {},
@ -105,6 +108,27 @@ void main() {
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', () {
final tall = Column(
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"}]}]}}',
),
);
await until((s) => s.importing);
final imported = await until((s) => s.importResult != null);
expect(imported.importing, isFalse);
expect(imported.importResult, (
title: 'Particles',
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);
});
}