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:
parent
e32a3129f6
commit
2283999ca9
9 changed files with 320 additions and 197 deletions
|
|
@ -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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
),
|
||||||
),
|
},
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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')],
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue