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