feat(app): shared widgets for the library

The pieces the library's cards and its create/edit sheet are built from, each
with its tests:

- TitleWithKicker: a title with an optional kicker above it (a studyset's
  secondary title, uppercased in the primary colour), a trailing action lined
  up with the title, and a reserved kicker line so cards side by side line up.
- ContentCountsLine: "3 studysets • 52 flashcards", leaving out what is
  unknown or zero, with a placeholder when nothing is left; labels are plural
  messages, so every language words its own counts.
- showAdaptiveSheet and SheetScaffold: a bottom sheet below expanded windows
  and a dialog from there on (a bottom sheet is a touch surface; expanded
  windows are mostly desktops). The title with its close button and the
  footer stay in view, only the body scrolls; the close button asks the
  route to close, so a sheet can ask before discarding changes.
- showUndoSnackBar: what just happened, with Undo for three seconds.
- ScrollHidingFab: a floating action button that slides away while reading
  onwards and returns on scrolling up, and stays where nothing scrolls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-01 00:33:44 +02:00
commit 2c1c7d3e42
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
10 changed files with 726 additions and 0 deletions

View file

@ -0,0 +1,184 @@
import 'dart:math' as math;
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/layout/window_size.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Shows [builder]'s sheet, usually a [SheetScaffold], as a bottom sheet in
/// windows below expanded and as a dialog from there on; completes with the
/// sheet's result either way.
///
/// Expanded rather than Material's medium, because a bottom sheet is a touch
/// surface: medium windows are mostly tablets, expanded ones mostly
/// desktops, where a sheet rising from the bottom of a monitor reads as a
/// phone screen moved over.
Future<T?> showAdaptiveSheet<T>(
BuildContext context, {
required WidgetBuilder builder,
}) {
if (!context.windowSize.isAtLeastExpanded) {
return showModalBottomSheet<T>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
showDragHandle: true,
builder: builder,
);
}
return showDialog<T>(
context: context,
builder: (context) => Dialog(
// More room than an alert's default: sheets hold forms and lists.
insetPadding: const EdgeInsets.symmetric(horizontal: 40, vertical: 32),
clipBehavior: Clip.antiAlias,
child: _Presentation(isDialog: true, child: builder(context)),
),
);
}
/// Tells a [SheetScaffold] whether it shows as a dialog.
class const _Presentation({
required final bool isDialog,
required super.child,
}) extends InheritedWidget {
static bool isDialogAt(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_Presentation>()?.isDialog ??
false;
@override
bool updateShouldNotify(_Presentation oldWidget) =>
isDialog != oldWidget.isDialog;
}
/// The frame of a sheet from [showAdaptiveSheet]: the [title] with a close
/// button on top, the [body] below, and the [footer], usually the action that
/// completes the sheet, at the bottom. Only the body scrolls, so the close
/// button and the footer stay in view however long the body grows.
///
/// As a bottom sheet it hugs its content up to 85 % of the window's height,
/// or always takes [heightFactor] of it when that is set: a long form whose
/// optional parts come and go then doesn't jump. It stays above the keyboard.
/// As a dialog it hugs its content, at most 640 dp high and 720 dp wide
/// however large the monitor, since a share of a big screen is a slab.
class const SheetScaffold({
/// The sheet's title.
required final String title,
/// The content; a scroll view holds it, so it is a plain column rather
/// than a list.
required final Widget body,
/// Below the content, always in view.
final Widget? footer,
/// What the close button does; by default it asks the sheet's route to
/// close, which a `PopScope` in the sheet can stop (to ask before
/// discarding changes, say).
final VoidCallback? onClose,
/// The share of the window's height a bottom sheet takes; it hugs its
/// content when null.
final double? heightFactor,
super.key,
}) extends StatelessWidget {
/// The widest a sheet grows as a dialog.
static const dialogMaxWidth = 720.0;
/// The tallest a sheet grows as a dialog.
static const dialogMaxHeight = 640.0;
@override
Widget build(BuildContext context) {
final media = MediaQuery.of(context);
final isDialog = _Presentation.isDialogAt(context);
final heightFactor = this.heightFactor;
final column = Column(
mainAxisSize: isDialog || heightFactor == null
? MainAxisSize.min
: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Header(title: title, onClose: onClose),
// 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)
Expanded(child: SingleChildScrollView(child: body))
else
Flexible(child: SingleChildScrollView(child: body)),
if (footer case final footer?) ...[const SizedBox(height: 12), footer],
],
);
if (isDialog) {
// The dialog keeps clear of the window's edges and the keyboard
// itself; insetting here as well would count them twice.
return ConstrainedBox(
constraints: BoxConstraints(
maxWidth: dialogMaxWidth,
maxHeight: math.min(dialogMaxHeight, media.size.height - 64),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 20),
child: column,
),
);
}
// The drag handle above gives the top its space.
const padding = EdgeInsets.fromLTRB(20, 0, 20, 16);
// What's left once the keyboard and the system's bottom inset are
// taken: raising the keyboard shrinks the body rather than pushing the
// sheet off the window.
final available =
media.size.height -
media.viewInsets.bottom -
media.padding.bottom -
padding.vertical;
return Padding(
padding: EdgeInsets.only(bottom: media.viewInsets.bottom),
child: SafeArea(
top: false,
child: Padding(
padding: padding,
child: heightFactor == null
? ConstrainedBox(
constraints: BoxConstraints(
maxHeight: math.min(media.size.height * 0.85, available),
),
child: column,
)
: SizedBox(
height: math.min(media.size.height * heightFactor, available),
child: column,
),
),
),
);
}
}
/// 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.
class const _Header({
required final String title,
required final VoidCallback? onClose,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => Row(
children: [
Expanded(
child: Text(
title,
style: Theme.of(context).textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
tooltip: AppLocalizations.of(context).commonClose,
icon: const Icon(LucideIcons.x),
onPressed: onClose ?? () => Navigator.of(context).maybePop(),
),
],
);
}

View file

@ -0,0 +1,38 @@
import 'package:material_ui/material_ui.dart';
/// One kind of content and how much of it there is: `count` is null while
/// unknown; `label` words it ("1 flashcard", "52 flashcards"), usually a
/// plural message from the app's localizations.
typedef ContentCount = ({int? count, String Function(int count) label});
/// What something holds, on one line: "3 studysets • 52 flashcards". Counts
/// that are unknown or zero are left out; with none left, [placeholder]
/// shows instead.
class const ContentCountsLine({
/// The counts, in the order they show.
required final List<ContentCount> counts,
/// Shown when nothing is counted; nothing shows while it is empty.
final String placeholder = '',
super.key,
}) extends StatelessWidget {
/// Between two counts.
static const separator = ' • ';
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final parts = [
for (final (:count, :label) in counts)
if (count != null && count > 0) label(count),
];
return Text(
parts.isEmpty ? placeholder : parts.join(separator),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
);
}
}

View file

@ -0,0 +1,70 @@
import 'package:flutter/rendering.dart';
import 'package:material_ui/material_ui.dart';
/// A floating action button that slides away while the person scrolls down,
/// reading onwards, and comes back as soon as they scroll up. [builder]
/// builds the screen around the button it is handed (as its scaffold's
/// floating action button); the scrolling in that screen moves it.
///
/// While hidden, the button can't be tapped through its fading ghost. A list
/// short enough not to scroll keeps it in view: it covers nothing there.
class const ScrollHidingFab({
/// The button; none when null, as in windows that put the action in the
/// app bar instead.
required final Widget? fab,
/// The screen, given the button to place.
required final Widget Function(BuildContext context, Widget? fab) builder,
super.key,
}) extends StatefulWidget {
@override
State<ScrollHidingFab> createState() => _ScrollHidingFabState();
}
class _ScrollHidingFabState() extends State<ScrollHidingFab> {
var _visible = true;
bool _onScroll(UserScrollNotification notification) {
// Only the screen's own vertical scrolling: a sideways swipe between
// tabs moves nothing.
if (notification.metrics.axis != Axis.vertical) {
return false;
}
final visible = switch (notification.direction) {
// A list that doesn't scroll covers nothing behind the button, and an
// overscroll bounce there mustn't hide it.
ScrollDirection.reverse => notification.metrics.maxScrollExtent <= 0,
ScrollDirection.forward => true,
ScrollDirection.idle => _visible,
};
if (visible != _visible) {
setState(() => _visible = visible);
}
return false;
}
@override
Widget build(BuildContext context) {
final fab = widget.fab;
return NotificationListener<UserScrollNotification>(
onNotification: _onScroll,
child: widget.builder(
context,
fab == null
? null
: AnimatedSlide(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
// Twice its own height clears the window's edge whatever
// the button's size.
offset: _visible ? Offset.zero : const Offset(0, 2),
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _visible ? 1 : 0,
child: IgnorePointer(ignoring: !_visible, child: fab),
),
),
),
);
}
}

View file

@ -0,0 +1,81 @@
import 'package:material_ui/material_ui.dart';
/// A title with an optional kicker above it: a short qualifier, uppercased,
/// letter-spaced and in the primary colour, such as a studyset's secondary
/// title. Without a kicker the title shows alone.
///
/// The title's style is the caller's, to suit each surface (a card, a list
/// row, an app bar); the kicker looks the same everywhere.
class const TitleWithKicker({
/// The title.
required final String title,
/// The kicker; none when null or empty.
final String? kicker,
/// The title's style.
final TextStyle? titleStyle,
/// The lines the title may take before it is cut short.
final int titleMaxLines = 2,
/// Beside the title, usually an action. It lines up with the title's
/// first line rather than with the kicker, and the title takes the rest
/// of the width.
final Widget? trailing,
/// Keeps the kicker's line free when there is no kicker, so titles (and
/// their [trailing] actions) side by side line up whether or not each has
/// one, as in a grid of cards.
final bool reserveKickerSpace = false,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final kickerStyle = theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
letterSpacing: 0.6,
);
final kicker = this.kicker;
final hasKicker = kicker != null && kicker.isNotEmpty;
final titleText = Text(
title,
style: titleStyle,
maxLines: titleMaxLines,
overflow: TextOverflow.ellipsis,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (hasKicker)
Text(
kicker.toUpperCase(),
style: kickerStyle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
else if (reserveKickerSpace)
// A line of the kicker's height, invisible; the no-break space
// gives it a height on every text engine.
Visibility.maintain(
visible: false,
child: Text(' ', style: kickerStyle, maxLines: 1),
),
if (hasKicker || reserveKickerSpace) const SizedBox(height: 2),
switch (trailing) {
final trailing? => Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: titleText),
trailing,
],
),
null => titleText,
},
],
);
}
}

View file

@ -0,0 +1,27 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:material_ui/material_ui.dart';
/// Says what just happened ([message]) and offers to undo it for three
/// seconds; [onUndo] runs when the person takes that offer. A new message
/// replaces the one showing.
void showUndoSnackBar(
BuildContext context, {
required String message,
required VoidCallback onUndo,
}) {
final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(message),
// Shorter than the default four seconds, which feel slow for
// something that already happened.
duration: const Duration(seconds: 3),
// A snack bar with an action stays until dismissed unless told
// otherwise; this one goes, like any other.
persist: false,
action: SnackBarAction(label: l10n.commonUndo, onPressed: onUndo),
),
);
}

View file

@ -0,0 +1,83 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:material_ui/material_ui.dart';
void main() {
/// Opens a sheet titled "New studyset" with [body] and a Create button, in
/// a window [width] wide.
Future<void> open(
WidgetTester tester, {
required double width,
Widget body = const Text('Body'),
}) async {
tester.view.physicalSize = Size(width, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Builder(
builder: (context) => TextButton(
onPressed: () => showAdaptiveSheet<void>(
context,
builder: (context) => SheetScaffold(
title: 'New studyset',
body: body,
footer: FilledButton(
onPressed: () {},
child: const Text('Create'),
),
),
),
child: const Text('Open'),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
}
testWidgets('is a bottom sheet below expanded windows', (tester) async {
await open(tester, width: 839);
expect(find.byType(BottomSheet), findsOneWidget);
expect(find.byType(Dialog), findsNothing);
});
testWidgets('is a dialog from expanded windows on', (tester) async {
await open(tester, width: 840);
expect(find.byType(Dialog), findsOneWidget);
expect(find.byType(BottomSheet), findsNothing);
});
for (final width in [400.0, 1200.0]) {
testWidgets('keeps its close button and footer in view above a long body '
'(${width.round()} wide)', (tester) async {
await open(
tester,
width: width,
body: Column(
children: [for (var i = 0; i < 80; i++) Text('Line $i')],
),
);
expect(find.byTooltip('Close').hitTestable(), findsOneWidget);
expect(find.text('Create').hitTestable(), findsOneWidget);
});
}
testWidgets('closes with its close button', (tester) async {
await open(tester, width: 400);
await tester.tap(find.byTooltip('Close'));
await tester.pumpAndSettle();
expect(find.text('New studyset'), findsNothing);
});
}

View file

@ -0,0 +1,45 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/widget/content_counts_line.dart';
import 'package:material_ui/material_ui.dart';
void main() {
Future<void> show(WidgetTester tester, Widget child) => tester.pumpWidget(
MaterialApp(home: Scaffold(body: child)),
);
String studysets(int count) => '$count studysets';
String flashcards(int count) => '$count flashcards';
String notes(int count) => '$count notes';
testWidgets('joins the counts that are known and not zero', (tester) async {
await show(
tester,
ContentCountsLine(
counts: [
(count: 3, label: studysets),
(count: 0, label: notes),
(count: null, label: notes),
(count: 52, label: flashcards),
],
placeholder: 'No content yet',
),
);
expect(find.text('3 studysets • 52 flashcards'), findsOneWidget);
});
testWidgets('shows the placeholder when nothing is counted', (tester) async {
await show(
tester,
ContentCountsLine(
counts: [
(count: 0, label: studysets),
(count: null, label: flashcards),
],
placeholder: 'No content yet',
),
);
expect(find.text('No content yet'), findsOneWidget);
});
}

View file

@ -0,0 +1,58 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/widget/scroll_hiding_fab.dart';
import 'package:material_ui/material_ui.dart';
void main() {
/// A list of [lines] with a button that hides while reading onwards.
Future<void> show(WidgetTester tester, {int lines = 100}) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
home: ScrollHidingFab(
builder: (context, fab) => Scaffold(
floatingActionButton: fab,
body: ListView(
children: [
for (var i = 0; i < lines; i++)
SizedBox(height: 48, child: Text('Line $i')),
],
),
),
fab: FloatingActionButton(
tooltip: 'New studyset',
onPressed: () {},
child: const Icon(Icons.add),
),
),
),
);
}
Finder fab() => find.byTooltip('New studyset').hitTestable();
testWidgets('hides while scrolling down and returns when scrolling up', (
tester,
) async {
await show(tester);
expect(fab(), findsOneWidget);
await tester.drag(find.byType(ListView), const Offset(0, -300));
await tester.pumpAndSettle();
expect(fab(), findsNothing);
await tester.drag(find.byType(ListView), const Offset(0, 100));
await tester.pumpAndSettle();
expect(fab(), findsOneWidget);
});
testWidgets('stays when the list fits without scrolling', (tester) async {
await show(tester, lines: 3);
await tester.drag(find.byType(ListView), const Offset(0, -300));
await tester.pumpAndSettle();
expect(fab(), findsOneWidget);
});
}

View file

@ -0,0 +1,85 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/widget/title_with_kicker.dart';
import 'package:material_ui/material_ui.dart';
void main() {
Future<void> show(WidgetTester tester, Widget child) => tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Align(
alignment: Alignment.topLeft,
child: SizedBox(width: 400, child: child),
),
),
),
);
double top(WidgetTester tester, Finder finder) =>
tester.getTopLeft(finder).dy;
testWidgets('shows the kicker uppercased above the title', (tester) async {
await show(
tester,
const TitleWithKicker(title: 'Vocabulary', kicker: 'Japanese'),
);
expect(find.text('JAPANESE'), findsOneWidget);
expect(
top(tester, find.text('JAPANESE')),
lessThan(top(tester, find.text('Vocabulary'))),
);
});
testWidgets('shows the title alone without a kicker', (tester) async {
await show(tester, const TitleWithKicker(title: 'Vocabulary', kicker: ''));
expect(top(tester, find.text('Vocabulary')), 0);
});
testWidgets('can keep the kicker line free, so titles side by side line '
'up', (tester) async {
await show(
tester,
const Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TitleWithKicker(title: 'Vocabulary', kicker: 'JLPT'),
),
Expanded(
child: TitleWithKicker(title: 'Grammar', reserveKickerSpace: true),
),
],
),
);
expect(
top(tester, find.text('Grammar')),
top(tester, find.text('Vocabulary')),
);
});
testWidgets('puts the trailing widget beside the title, not the kicker', (
tester,
) async {
const action = Key('action');
await show(
tester,
const TitleWithKicker(
title: 'Vocabulary',
kicker: 'Japanese',
trailing: SizedBox(key: action, width: 48, height: 48),
),
);
expect(
top(tester, find.byKey(action)),
top(tester, find.text('Vocabulary')),
);
expect(
tester.getTopRight(find.byKey(action)).dx,
400,
reason: 'the title takes the rest of the width',
);
});
}

View file

@ -0,0 +1,55 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
import 'package:material_ui/material_ui.dart';
void main() {
late int undos;
setUp(() => undos = 0);
/// Shows "Studyset archived" with a way to undo it.
Future<void> archive(WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Builder(
builder: (context) => TextButton(
onPressed: () => showUndoSnackBar(
context,
message: 'Studyset archived',
onUndo: () => undos++,
),
child: const Text('Archive'),
),
),
),
),
);
await tester.tap(find.text('Archive'));
await tester.pumpAndSettle();
}
testWidgets('offers to undo', (tester) async {
await archive(tester);
expect(find.text('Studyset archived'), findsOneWidget);
await tester.tap(find.text('Undo'));
await tester.pumpAndSettle();
expect(undos, 1);
expect(find.text('Studyset archived'), findsNothing);
});
testWidgets('goes by itself after three seconds', (tester) async {
await archive(tester);
await tester.pump(const Duration(seconds: 3));
await tester.pumpAndSettle();
expect(find.text('Studyset archived'), findsNothing);
expect(undos, 0);
});
}