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:
parent
c21ce6232b
commit
2c1c7d3e42
10 changed files with 726 additions and 0 deletions
184
halkyon_learn_flutter/lib/src/core/widget/adaptive_sheet.dart
Normal file
184
halkyon_learn_flutter/lib/src/core/widget/adaptive_sheet.dart
Normal 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(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue