Halkyon's shared packages

A pub workspace of the Dart packages Halkyon's apps share: halkyon_ui,
Halkyon's look (the theme, fonts, layout and shared widgets, with texts
in English and German), halkyon_core's low-level primitives and
halkyon_interop's cross-app contracts. Dart 3.13 with LeanCode's lints,
configured once at the root; apps take a package by git, pinned to a
signed commit.

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-18 21:46:55 +02:00
commit 80fa7b9628
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
36 changed files with 6762 additions and 0 deletions

View file

@ -0,0 +1,3 @@
/// The widest body text should run: about 720 dp, which keeps lines within
/// the 60 to 80 characters that read most easily.
const double readableContentMaxWidth = 720;

View file

@ -0,0 +1,41 @@
import 'package:material_ui/material_ui.dart';
/// Material 3's window size classes, by width in logical pixels: compact
/// below 600, medium to 840, expanded to 1200, large to 1600, extra large
/// beyond.
///
/// Layouts branch on two of the lines: from medium on, navigation is a rail
/// at the side instead of a bar at the bottom; from expanded on, the layout
/// is a desktop one (sheets open as dialogs, a screen may show two
/// columns). Expanded rather than medium for the latter because medium
/// windows are mostly tablets, used by touch, and expanded ones mostly
/// desktops.
enum WindowSize {
compact,
medium,
expanded,
large,
extraLarge;
/// The class of a window [width] wide.
factory fromWidth(double width) => switch (width) {
< 600 => compact,
< 840 => medium,
< 1200 => expanded,
< 1600 => large,
_ => extraLarge,
};
/// Whether navigation is a rail rather than a bottom bar.
bool get isAtLeastMedium => index >= medium.index;
/// Whether the layout is a desktop one.
bool get isAtLeastExpanded => index >= expanded.index;
}
/// `context.windowSize`: the class of the window [BuildContext] is in.
extension WindowSizeOf on BuildContext {
/// Rebuilds the caller when the window's width changes.
WindowSize get windowSize =>
WindowSize.fromWidth(MediaQuery.sizeOf(this).width);
}

View file

@ -0,0 +1,77 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// The font of every Halkyon app, the same on every device: Noto Sans, for
/// Latin, Greek (polytonic too) and Cyrillic. Bundled with this package,
/// never fetched, so text looks alike everywhere and needs nothing the
/// device may lack; an app finds a package's font by this name.
const appFontFamily = 'packages/halkyon_ui/Noto Sans';
/// The fonts for Chinese, Japanese and Korean, which fill in where
/// [appFontFamily] has no glyph. They take some 42 MB, so each app bundles
/// those it needs itself, declaring them under exactly these family names
/// (an app's own fonts take no `packages/` prefix); a name no font answers
/// to falls through to the device's own fonts.
///
/// They share most Han characters but draw them differently, and the first
/// in a list that has a character draws it: so Japanese comes first unless
/// the text's language asks for another ([appFontFallbackFor]). The scripts
/// none of them covers (Arabic, Hebrew, Thai, …) come from the device's own
/// fonts.
const _japanese = 'Noto Sans JP';
const _simplifiedChinese = 'Noto Sans SC';
const _traditionalChinese = 'Noto Sans TC';
const _korean = 'Noto Sans KR';
/// What fills in where [appFontFamily] has no glyph, with no language
/// given: Japanese shapes first, then Chinese and Korean (Hangul only the
/// Korean font has).
const appFontFallback = [
_japanese,
_simplifiedChinese,
_traditionalChinese,
_korean,
];
/// [appFontFallback] for text in [language], a BCP 47 tag: its own font
/// first when it is Chinese, Japanese or Korean, so that the Han characters
/// it shares with the others take its shapes; for any other language, or
/// none, [appFontFallback].
List<String> appFontFallbackFor(String? language) {
final first = _ownFont(language);
return switch (first) {
null => appFontFallback,
final first => [first, ...appFontFallback.where((font) => font != first)],
};
}
/// Whether [language] (a BCP 47 tag) has a font of its own among the CJK
/// fonts: Chinese, Japanese and Korean, whose Han characters each draw in
/// their own shapes.
bool hasOwnFont(String? language) => _ownFont(language) != null;
String? _ownFont(String? language) {
final parts = (language ?? '').toLowerCase().split('-');
return switch (parts.first) {
'ja' => _japanese,
'ko' => _korean,
// Traditional characters by their script (`zh-Hant`) or a region that
// writes them (Taiwan, Hong Kong, Macau).
'zh' when const ['hant', 'tw', 'hk', 'mo'].any(parts.contains) =>
_traditionalChinese,
'zh' => _simplifiedChinese,
_ => null,
};
}
/// Adds Noto Sans's licence to the app's licence list, beside the packages'
/// own: the SIL Open Font License asks that it travels with the font. An
/// app bundling the CJK fonts adds their licence itself.
void registerFontLicenses() => LicenseRegistry.addLicense(() async* {
yield LicenseEntryWithLineBreaks(
['Noto Sans'],
await rootBundle.loadString(
'packages/halkyon_ui/assets/fonts/OFL-NotoSans.txt',
),
);
});

View file

@ -0,0 +1,201 @@
import 'package:halkyon_ui/src/layout/window_size.dart';
import 'package:halkyon_ui/src/theme/app_fonts.dart';
import 'package:halkyon_ui/src/theme/brand.dart';
import 'package:halkyon_ui/src/theme/theme_extras.dart';
import 'package:material_ui/material_ui.dart';
/// A Halkyon app's theme for [brightness]: Material 3 with a hand-authored
/// colour scheme around cobalt and copper, Halkyon's font
/// ([appFontFamily]), [ThemeExtras] and the app's own [extensions] beside
/// them, the current style of progress indicators, Material 3 Expressive's
/// shorter navigation bar, and snack bars floating above it
/// ([FittedSnackBars]).
ThemeData appTheme(
Brightness brightness, {
Iterable<ThemeExtension<dynamic>> extensions = const [],
}) {
final theme = _baseTheme(brightness, extensions);
return theme.copyWith(navigationBarTheme: _flexibleNavigationBar(theme));
}
/// Material 3 Expressive's flexible navigation bar, which Flutter's Material
/// library doesn't have yet (checked with v1.6.0): 64 dp high instead of 80,
/// the label of the tab in view in the secondary colour (the tokens of
/// Compose's `ShortNavigationBar`). Its pill stays Flutter's, 64 dp wide
/// rather than the 56 dp the tokens give, as Flutter fixes it.
NavigationBarThemeData _flexibleNavigationBar(ThemeData theme) {
final scheme = theme.colorScheme;
final label = theme.textTheme.labelMedium;
return NavigationBarThemeData(
height: 64,
labelTextStyle: WidgetStateProperty.resolveWith(
(states) => label?.copyWith(
color: states.contains(WidgetState.disabled)
? scheme.onSurfaceVariant.withValues(alpha: 0.38)
: states.contains(WidgetState.selected)
? scheme.secondary
: scheme.onSurfaceVariant,
),
),
);
}
ThemeData _baseTheme(
Brightness brightness,
Iterable<ThemeExtension<dynamic>> extensions,
) => ThemeData(
useMaterial3: true,
fontFamily: appFontFamily,
fontFamilyFallback: appFontFallback,
colorScheme: switch (brightness) {
Brightness.light => _lightScheme(),
Brightness.dark => _darkScheme(),
},
// Rounded ends, a gap in the track and a stop indicator, instead of the
// older style. `false` is the only way to opt in so far; the flag is
// deprecated because it will become the default.
// ignore: deprecated_member_use
progressIndicatorTheme: const ProgressIndicatorThemeData(year2023: false),
// App bars stay flat when content scrolls under them: the tint and shadow
// Material 3 adds flicker as an overscroll crosses the edge again and
// again (Flutter issue 124296), and the app reads flat throughout.
appBarTheme: const AppBarThemeData(
scrolledUnderElevation: 0,
surfaceTintColor: Colors.transparent,
),
// Floating, as Material 3 places snack bars: above the navigation bar
// rather than across the window's foot beneath it, in Material 3's
// inverse colours and corners (4 dp); in from the sides and above the bar
// as far as the floating bar stands in and above the system's
// (FloatingNavigationBar.sideMargin and lift). Flutter's default is the
// fixed snack bar of Material 2.
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
insetPadding: EdgeInsets.fromLTRB(16, 8, 16, 8),
),
extensions: [
switch (brightness) {
Brightness.light => ThemeExtras.light,
Brightness.dark => ThemeExtras.dark,
},
...extensions,
],
);
// The colour schemes are written by hand rather than derived from the cobalt
// seed, so that the whole interface, surfaces and neutrals included, reads as
// one family with the picker palette (PaletteColor) instead of Material's
// grey with vivid chips on top. The chromatic roles are palette colours:
// primary cobalt, secondary slate blue (a livelier blue for the rarely used
// role), tertiary copper (the warm accent), error cerise. The neutrals carry a
// cool, cobalt-tinted undertone: near-white blues in light mode, a deep
// navy-slate in dark mode. `fromSeed` still fills the roles the interface
// rarely shows (shadow, scrim, the fixed colours).
const _white = Color(0xFFFFFFFF);
ColorScheme _lightScheme() =>
ColorScheme.fromSeed(
seedColor: Brand.primary,
tertiary: Brand.accent,
).copyWith(
primary: Brand.primary,
onPrimary: _white,
primaryContainer: const Color(0xFFDFE2F9),
onPrimaryContainer: const Color(0xFF0A175C),
secondary: const Color(0xFF415DF0),
onSecondary: _white,
secondaryContainer: const Color(0xFFE1E5FB),
onSecondaryContainer: const Color(0xFF101A66),
tertiary: Brand.accent,
onTertiary: _white,
tertiaryContainer: const Color(0xFFFFDCC9),
onTertiaryContainer: const Color(0xFF481500),
error: const Color(0xFFBA1E55),
onError: _white,
errorContainer: const Color(0xFFFFD9E1),
onErrorContainer: const Color(0xFF45001B),
surface: const Color(0xFFF7F9FF),
onSurface: const Color(0xFF191B24),
onSurfaceVariant: const Color(0xFF474B5C),
surfaceDim: const Color(0xFFD6DDF0),
surfaceBright: const Color(0xFFF8FAFF),
surfaceContainerLowest: _white,
surfaceContainerLow: const Color(0xFFEDF1FC),
surfaceContainer: const Color(0xFFE3E9F8),
surfaceContainerHigh: const Color(0xFFD8E0F3),
surfaceContainerHighest: const Color(0xFFCDD7EF),
outline: const Color(0xFF757A8C),
outlineVariant: const Color(0xFFC4CADB),
inverseSurface: const Color(0xFF2D2F3B),
onInverseSurface: const Color(0xFFEEF1FB),
inversePrimary: const Color(0xFFAEB9FF),
surfaceTint: Brand.primary,
);
ColorScheme _darkScheme() =>
ColorScheme.fromSeed(
seedColor: Brand.primary,
brightness: Brightness.dark,
tertiary: Brand.accent,
).copyWith(
primary: const Color(0xFFAEB9FF),
onPrimary: const Color(0xFF0A1668),
primaryContainer: const Color(0xFF1E2E8C),
onPrimaryContainer: const Color(0xFFDFE2F9),
secondary: const Color(0xFFB7C0F7),
onSecondary: const Color(0xFF14225E),
secondaryContainer: const Color(0xFF2E3C9E),
onSecondaryContainer: const Color(0xFFE3E7FD),
tertiary: const Color(0xFFFFB694),
onTertiary: const Color(0xFF5A1E00),
tertiaryContainer: const Color(0xFF9A3600),
onTertiaryContainer: const Color(0xFFFFDCC9),
error: const Color(0xFFFF9EBB),
onError: const Color(0xFF5E0022),
errorContainer: const Color(0xFF8E1244),
onErrorContainer: const Color(0xFFFFD9E1),
surface: const Color(0xFF131520),
onSurface: const Color(0xFFE5E7F2),
onSurfaceVariant: const Color(0xFFC0C4D6),
surfaceDim: const Color(0xFF131520),
surfaceBright: const Color(0xFF363948),
surfaceContainerLowest: const Color(0xFF0D0F18),
surfaceContainerLow: const Color(0xFF191C2A),
surfaceContainer: const Color(0xFF1D2130),
surfaceContainerHigh: const Color(0xFF272B3C),
surfaceContainerHighest: const Color(0xFF323648),
outline: const Color(0xFF8A8FA3),
outlineVariant: const Color(0xFF434862),
inverseSurface: const Color(0xFFE5E7F2),
onInverseSurface: const Color(0xFF1D2130),
inversePrimary: Brand.primary,
surfaceTint: const Color(0xFFAEB9FF),
);
/// Snack bars no wider than [maxWidth] from medium windows on, centred,
/// rather than across the whole window; below, the theme's margins line
/// them up with the floating navigation bar. Around the app's navigator
/// (`MaterialApp.builder`), since the theme can't know the window.
class const FittedSnackBars({required final Widget child, super.key})
extends StatelessWidget {
/// How wide a snack bar is at most, from medium windows on.
static const maxWidth = 560.0;
/// The same widgets above [child] in every window, only the theme in
/// them changing: a widget added or left out above it as the window
/// crosses a size would build the app's navigator, and every screen in
/// it, anew.
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Theme(
data: context.windowSize.isAtLeastMedium
? theme.copyWith(
snackBarTheme: theme.snackBarTheme.copyWith(width: maxWidth),
)
: theme,
child: child,
);
}
}

View file

@ -0,0 +1,13 @@
import 'package:material_ui/material_ui.dart';
/// Halkyon's brand colours, taken from the kingfisher of its icon, the same
/// in every Halkyon app.
abstract final class Brand() {
/// Cobalt blue: the primary colour, recurring across the icon's gradients.
static const Color primary = Color(0xFF273EB2);
/// Deep copper orange: the accent, a warm contrast to the cobalt. Set as
/// the colour scheme's tertiary, so it stays this exact orange instead of
/// one derived from the cobalt.
static const Color accent = Color(0xFFC44800);
}

View file

@ -0,0 +1,58 @@
import 'package:material_ui/material_ui.dart';
/// The colours a person can give content (a set of cards, a list, a tag):
/// twenty named colours, the names of Proton's label colours.
///
/// They are not theme colours: something someone made cobalt stays cobalt in
/// light and dark mode alike. Content stores a colour by its [name], the
/// same in every Halkyon app.
///
/// Each colour is a triple: the [background] fill (bars, chips, dots), the
/// [foreground] for text and icons on that fill (black or white, whichever
/// contrasts more; every pair reaches WCAG AA, which a test guards), and a
/// darker [outline] for borders and focus rings.
enum PaletteColor(
/// The fill.
final Color background,
/// Text and icons on [background].
final Color foreground,
/// Borders and focus rings, where the fill alone lacks definition.
final Color outline,
) {
carrot(Color(0xFFF78400), _black, Color(0xFFBA680A)),
cerise(Color(0xFFBA1E55), _white, Color(0xFF861F43)),
cobalt(Color(0xFF273EB2), _white, Color(0xFF263580)),
copper(Color(0xFFC44800), _white, Color(0xFF8A3707)),
enzian(Color(0xFF5252CC), _white, Color(0xFF4141AA)),
fern(Color(0xFF3CBB3A), _black, Color(0xFF398A38)),
forest(Color(0xFF258723), _white, Color(0xFF20591E)),
ocean(Color(0xFF0A77A6), _white, Color(0xFF0D5270)),
olive(Color(0xFFB4A40E), _black, Color(0xFF7D7311)),
pacific(Color(0xFF179FD9), _black, Color(0xFF1C79A1)),
pickle(Color(0xFF807304), _white, Color(0xFF4A4307)),
pine(Color(0xFF0F735A), _white, Color(0xFF0D4235)),
pink(Color(0xFFDB60D7), _black, Color(0xFFC543C0)),
plum(Color(0xFFA939A4), _white, Color(0xFF793577)),
purple(Color(0xFF8080FF), _black, Color(0xFF5656F6)),
reef(Color(0xFF1DA583), _black, Color(0xFF1D725D)),
sahara(Color(0xFF936D59), _white, Color(0xFF6A584E)),
slateblue(Color(0xFF415DF0), _white, Color(0xFF1F3EDF)),
soil(Color(0xFF54473F), _white, Color(0xFF32302E)),
strawberry(Color(0xFFEC3E7C), _black, Color(0xFFD52263));
/// The colour stored as [name], or null for none or an unknown name, e.g.
/// one a newer app version added.
static PaletteColor? byName(String? name) {
for (final color in values) {
if (color.name == name) {
return color;
}
}
return null;
}
}
const _black = Color(0xFF000000);
const _white = Color(0xFFFFFFFF);

View file

@ -0,0 +1,73 @@
import 'package:material_ui/material_ui.dart';
/// The status colours Material's colour scheme has no role for, for
/// feedback: success, warning and info (error is in the scheme). An app's
/// own colours (a study app's ratings, say) are an extension of its own,
/// passed to `appTheme` beside these.
final class const ThemeExtras({
required final Color success,
required final Color onSuccess,
required final Color warning,
required final Color onWarning,
required final Color info,
required final Color onInfo,
}) extends ThemeExtension<ThemeExtras> {
/// On light surfaces.
static const light = ThemeExtras(
success: Color(0xFF1EA885),
onSuccess: Color(0xFFFFFFFF),
warning: Color(0xFFFF9900),
onWarning: Color(0xFF000000),
info: Color(0xFF239ECE),
onInfo: Color(0xFFFFFFFF),
);
/// On dark surfaces: a little brighter, for contrast.
static const dark = ThemeExtras(
success: Color(0xFF35B191),
onSuccess: Color(0xFFFFFFFF),
warning: Color(0xFFFFA31A),
onWarning: Color(0xFF000000),
info: Color(0xFF39A8D3),
onInfo: Color(0xFFFFFFFF),
);
@override
ThemeExtras copyWith({
Color? success,
Color? onSuccess,
Color? warning,
Color? onWarning,
Color? info,
Color? onInfo,
}) => ThemeExtras(
success: success ?? this.success,
onSuccess: onSuccess ?? this.onSuccess,
warning: warning ?? this.warning,
onWarning: onWarning ?? this.onWarning,
info: info ?? this.info,
onInfo: onInfo ?? this.onInfo,
);
@override
ThemeExtras lerp(ThemeExtras? other, double t) {
if (other == null) {
return this;
}
Color mix(Color a, Color b) => Color.lerp(a, b, t)!;
return ThemeExtras(
success: mix(success, other.success),
onSuccess: mix(onSuccess, other.onSuccess),
warning: mix(warning, other.warning),
onWarning: mix(onWarning, other.onWarning),
info: mix(info, other.info),
onInfo: mix(onInfo, other.onInfo),
);
}
}
/// `Theme.of(context).extras`.
extension ThemeExtrasOf on ThemeData {
/// This theme's [ThemeExtras]; every theme from `appTheme` has them.
ThemeExtras get extras => extension<ThemeExtras>()!;
}

View file

@ -0,0 +1,392 @@
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:halkyon_ui/l10n/halkyon_ui_localizations.dart';
import 'package:halkyon_ui/src/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.
///
/// A sheet that asks before discarding changes (with a `PopScope`) passes
/// [swipeable] false: a bottom sheet swiped away closes without asking its
/// `PopScope` (Flutter issue 152120, open with v3.47), so it then has no
/// drag handle, and closes by its close button, the back gesture or a tap
/// beside it, which all ask.
Future<T?> showAdaptiveSheet<T>(
BuildContext context, {
required WidgetBuilder builder,
bool swipeable = true,
}) {
if (!context.windowSize.isAtLeastExpanded) {
return showModalBottomSheet<T>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
// A sheet as tall as the window, as one is with the keyboard open,
// would otherwise reach under the status bar.
useSafeArea: true,
showDragHandle: swipeable,
enableDrag: swipeable,
builder: (context) => _Presentation(
isDialog: false,
hasDragHandle: swipeable,
child: builder(context),
),
);
}
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,
hasDragHandle: false,
child: builder(context),
),
),
);
}
/// Tells a [SheetScaffold] how it shows: as a dialog or a bottom sheet, and
/// whether a drag handle sits above it.
class const _Presentation({
required final bool isDialog,
required final bool hasDragHandle,
required super.child,
}) extends InheritedWidget {
/// How the sheet at [context] shows; a sheet shown some other way is a
/// bottom sheet with a handle, as Material's own are.
static ({bool isDialog, bool hasDragHandle}) at(BuildContext context) =>
switch (context.dependOnInheritedWidgetOfExactType<_Presentation>()) {
final presentation? => (
isDialog: presentation.isDialog,
hasDragHandle: presentation.hasDragHandle,
),
null => (isDialog: false, hasDragHandle: true),
};
@override
bool updateShouldNotify(_Presentation oldWidget) =>
isDialog != oldWidget.isDialog ||
hasDragHandle != oldWidget.hasDragHandle;
}
/// 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,
/// Beside the title, before the close button: an action on the sheet as a
/// whole, such as managing what it lists.
final Widget? action,
/// 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,
/// Whether snack bars shown from inside the sheet show on it, where they
/// can be acted on (an Undo, say); those of the screens below would sit
/// behind its barrier. The sheet then always takes its largest height.
final bool hasSnackBars = false,
/// Whether something runs that mustn't be cut off, as an import: the
/// close button is off, greyed, and back does nothing until it ends. A
/// sheet that can be busy is shown with `swipeable` false, as a bottom
/// sheet swiped away closes whatever it holds says.
final bool busy = false,
super.key,
}) extends StatelessWidget {
/// The widest a sheet grows as a dialog.
static const dialogMaxWidth = 720.0;
/// The tallest a sheet grows as a dialog.
static const dialogMaxHeight = 640.0;
/// The line under the title while some of the body lies above.
static const moreAboveKey = ValueKey('sheet-more-above');
/// The line above the footer while some of the body lies below.
static const moreBelowKey = ValueKey('sheet-more-below');
@override
Widget build(BuildContext context) {
final media = MediaQuery.of(context);
final presentation = _Presentation.at(context);
final isDialog = presentation.isDialog;
final heightFactor = this.heightFactor;
// The sides' room goes inside the scroll view, not around it: a scroll
// view cuts what it holds at its edges, outlines' outer half pixel
// included, and its scrollbar runs along its edge.
final side = isDialog ? 24.0 : 20.0;
Widget inset(Widget child) => Padding(
padding: EdgeInsets.symmetric(horizontal: side),
child: child,
);
final column = Column(
mainAxisSize: isDialog || heightFactor == null
? MainAxisSize.min
: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
inset(
_Header(
title: title,
action: action,
onClose: busy
? null
: onClose ?? () => Navigator.of(context).maybePop(),
),
),
// Loose, so a short body keeps the sheet short; tight at a fixed
// height, so the footer sits at the bottom.
if (!isDialog && heightFactor != null)
Expanded(
child: _ScrollingBody(
body: body,
side: side,
fill: true,
footer: footer,
),
)
else
Flexible(
child: _ScrollingBody(body: body, side: side, footer: footer),
),
if (footer case final footer?) ...[
const SizedBox(height: 12),
inset(footer),
],
],
);
final content = PopScope(
canPop: !busy,
child: hasSnackBars
? ScaffoldMessenger(
child: Scaffold(
backgroundColor: Colors.transparent,
// The sheet keeps clear of the keyboard itself.
resizeToAvoidBottomInset: false,
body: column,
),
)
: column,
);
if (isDialog) {
// The dialog keeps clear of the window's edges and the keyboard
// itself; insetting here as well would count them twice.
return ConstrainedBox(
constraints: BoxConstraints(
maxWidth: dialogMaxWidth,
maxHeight: math.min(dialogMaxHeight, media.size.height - 64),
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
child: content,
),
);
}
// A drag handle above gives the top its space.
final padding = EdgeInsets.fromLTRB(
0,
presentation.hasDragHandle ? 0 : 16,
0,
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: content,
)
: SizedBox(
height: math.min(media.size.height * heightFactor, available),
child: content,
),
),
),
);
}
}
/// The sheet's title and its close button, off without [onClose]. The
/// button is there beside swiping the sheet away, which fails with wet
/// fingers, gloves or a sheet taller than the window, and a dialog can't be
/// swiped at all.
class const _Header({
required final String title,
required final Widget? action,
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,
),
),
?action,
IconButton(
tooltip: HalkyonUiLocalizations.of(context).close,
icon: const Icon(LucideIcons.x),
onPressed: onClose,
),
],
);
}
/// The sheet's body, scrolling, with a scrollbar beside it (always shown on
/// the desktop, where a wheel or touchpad scrolls without showing one) and,
/// as Material 3 has scrolling dialogs, a line under the title while some of
/// it lies above, and one above the [footer] while some of it lies below
/// (a form's last switches went unnoticed below the buttons without). [fill] takes the whole height given, rather than hugging the
/// body.
class const _ScrollingBody({
required final Widget body,
required final Widget? footer,
/// The room at its sides, inside the scroll view.
required final double side,
final bool fill = false,
}) extends StatefulWidget {
@override
State<_ScrollingBody> createState() => _ScrollingBodyState();
}
const _bodyKey = ValueKey('sheet-body');
class _ScrollingBodyState() extends State<_ScrollingBody> {
final _scroll = ScrollController();
var _moreAbove = false;
var _moreBelow = false;
void _update(ScrollMetrics metrics) {
final moreAbove = metrics.pixels > metrics.minScrollExtent;
final moreBelow = metrics.pixels < metrics.maxScrollExtent;
if (moreAbove != _moreAbove || moreBelow != _moreBelow) {
setState(() {
_moreAbove = moreAbove;
_moreBelow = moreBelow;
});
}
}
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final desktop = switch (defaultTargetPlatform) {
TargetPlatform.linux ||
TargetPlatform.windows ||
TargetPlatform.macOS => true,
_ => false,
};
// The lines keep their room while hidden, so the body doesn't jump.
Widget line({required bool shown, required Key key}) => shown
? Divider(
key: key,
height: 1,
indent: widget.side,
endIndent: widget.side,
)
: const SizedBox(height: 1);
final scrolling = NotificationListener<ScrollMetricsNotification>(
// Also when the body's size changes, as a section opens.
onNotification: (notification) {
_update(notification.metrics);
return false;
},
child: NotificationListener<ScrollUpdateNotification>(
onNotification: (notification) {
_update(notification.metrics);
return false;
},
child: Scrollbar(
controller: _scroll,
thumbVisibility: desktop,
// In place of the one Material's scroll behaviour adds on the
// desktop, shown only while scrolling.
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(
context,
).copyWith(scrollbars: false),
child: SingleChildScrollView(
controller: _scroll,
padding: EdgeInsets.symmetric(horizontal: widget.side),
child: widget.body,
),
),
),
),
);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
line(shown: _moreAbove, key: SheetScaffold.moreAboveKey),
// Keyed, so that a line coming or going beside it keeps the scroll
// view and its position.
if (widget.fill)
Expanded(key: _bodyKey, child: scrolling)
else
Flexible(key: _bodyKey, child: scrolling),
if (widget.footer != null)
line(shown: _moreBelow, key: SheetScaffold.moreBelowKey),
],
);
}
}

View file

@ -0,0 +1,154 @@
import 'package:expressive_loading_indicator/expressive_loading_indicator.dart';
import 'package:material_ui/material_ui.dart';
/// The app's spinner for anything still loading: Material 3 Expressive's
/// morphing indicator, which Flutter doesn't ship yet. Every loading state
/// shows this widget, a button's too ([ButtonLoadingIndicator]) and pulling
/// a list down ([AppRefreshIndicator]), so swapping in the SDK's own
/// indicator one day changes one file. Determinate progress (a mastery
/// ring) stays a [CircularProgressIndicator].
class const AppLoadingIndicator({
/// The side of its square; Material's 48 dp when null.
final double? size,
/// Its colour; the theme's primary colour when null.
final Color? color,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => ExpressiveLoadingIndicator(
color: color,
constraints: switch (size) {
final size? => BoxConstraints.tightFor(width: size, height: size),
null => null,
},
);
}
/// The app's spinner in a button, in place of its icon or label while what
/// it started is under way: small, in the button's own colour.
class const ButtonLoadingIndicator({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) => AppLoadingIndicator(
size: 20,
color:
IconTheme.of(context).color ?? DefaultTextStyle.of(context).style.color,
);
}
/// Pulling [child] down runs [onRefresh], the app's spinner showing
/// meanwhile, as Material 3 Expressive's pull to refresh has it: in a small
/// round container that comes down with the pull and stays while it
/// refreshes. Flutter's own [RefreshIndicator] draws the older spinner, so
/// it only does the pulling here (its `noSpinner` kind).
class const AppRefreshIndicator({
required final RefreshCallback onRefresh,
required final Widget child,
super.key,
}) extends StatefulWidget {
/// How far down the container comes at most, and stays while refreshing.
static const restingTop = 24.0;
/// The container's side.
static const containerSize = 44.0;
@override
State<AppRefreshIndicator> createState() => _AppRefreshIndicatorState();
}
class _AppRefreshIndicatorState() extends State<AppRefreshIndicator> {
RefreshIndicatorStatus? _status;
/// How far the list was pulled past its top, in logical pixels.
var _pulled = 0.0;
bool _tracked(ScrollNotification notification) {
if (notification.depth != 0 || notification.metrics.axis != Axis.vertical) {
return false;
}
switch (notification) {
case ScrollStartNotification():
if (_status != RefreshIndicatorStatus.refresh) {
_pulled = 0;
}
case OverscrollNotification(:final overscroll)
when overscroll < 0 && notification.metrics.extentBefore == 0:
setState(() => _pulled -= overscroll);
case ScrollUpdateNotification(:final scrollDelta?)
when _pulled > 0 && scrollDelta > 0:
setState(() => _pulled = (_pulled - scrollDelta).clamp(0, _pulled));
// A list that bounces (iOS) goes past its top by itself.
case ScrollUpdateNotification(:final metrics)
when metrics.pixels < metrics.minScrollExtent:
setState(() => _pulled = metrics.minScrollExtent - metrics.pixels);
default:
break;
}
return false;
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final status = _status;
final refreshing =
status == RefreshIndicatorStatus.refresh ||
status == RefreshIndicatorStatus.snap;
final pulling =
status == RefreshIndicatorStatus.drag ||
status == RefreshIndicatorStatus.armed;
const size = AppRefreshIndicator.containerSize;
// Comes down with the pull, half as fast as the finger, to rest.
final top = refreshing
? AppRefreshIndicator.restingTop
: (_pulled / 2 - size).clamp(-size, AppRefreshIndicator.restingTop);
final shown = refreshing || (pulling && _pulled > 0);
return Stack(
children: [
NotificationListener<ScrollNotification>(
onNotification: _tracked,
child: RefreshIndicator.noSpinner(
onRefresh: widget.onRefresh,
onStatusChange: (status) => setState(() {
_status = status;
if (status == RefreshIndicatorStatus.done ||
status == RefreshIndicatorStatus.canceled) {
_pulled = 0;
}
}),
child: widget.child,
),
),
Positioned(
top: top,
left: 0,
right: 0,
child: IgnorePointer(
child: AnimatedOpacity(
opacity: shown ? 1 : 0,
duration: const Duration(milliseconds: 150),
child: Center(
child: Material(
color: colors.primaryContainer,
shape: const CircleBorder(),
elevation: 3,
child: SizedBox.square(
dimension: size,
child: Padding(
padding: const EdgeInsets.all(4),
child: shown
? AppLoadingIndicator(
color: colors.onPrimaryContainer,
)
: null,
),
),
),
),
),
),
),
],
);
}
}

View file

@ -0,0 +1,33 @@
import 'package:halkyon_ui/l10n/halkyon_ui_localizations.dart';
import 'package:material_ui/material_ui.dart';
/// "Halkyon" over the app's [product] word ("Learn", "Todo"), in Halkyon's
/// font, cobalt and copper as the logo has them. The logo's own typeface
/// (ITC Avant Garde) isn't free to ship with an app. Screen readers don't
/// hear it: the screen showing it names the app its own way.
class const BrandWordmark({
/// The app's own word below the brand's.
required final String product,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = HalkyonUiLocalizations.of(context);
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final word = theme.textTheme.displaySmall?.copyWith(
fontWeight: FontWeight.w800,
height: 1.05,
letterSpacing: -0.5,
);
return ExcludeSemantics(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.brand, style: word?.copyWith(color: scheme.primary)),
Text(product, style: word?.copyWith(color: scheme.tertiary)),
],
),
);
}
}

View file

@ -0,0 +1,79 @@
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// A form's field whose value is set on a screen of its own, or in a
/// dialog: it shows the [value], up to [maxLines] lines, or the
/// [placeholder] in italics while there is none, and a chevron leads on, as
/// tapping opens it. So the form never holds what needs a whole window (an
/// editor, a map) or a check of its own (a handle). Without [onTap] it only
/// shows the value.
class const ChevronField({
/// What the field holds, above it.
required final String label,
/// The value as text; null while there is none.
required final String? value,
/// What shows while there is no value, inviting to set one.
required final String placeholder,
/// Opens what sets the value; null where it can't change.
required final VoidCallback? onTap,
final int maxLines = 3,
/// What is said below the field.
final String? helperText,
/// Why the value was refused, below the field in place of [helperText].
final String? errorText,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
return InputDecorator(
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.fromLTRB(12, 12, 4, 12),
helperText: helperText,
helperMaxLines: 3,
errorText: errorText,
errorMaxLines: 3,
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Expanded(
child: Text(
value ?? placeholder,
maxLines: maxLines,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: value == null
? colors.onSurfaceVariant
: colors.onSurface,
fontStyle: value == null ? FontStyle.italic : null,
),
),
),
if (onTap != null) ...[
const SizedBox(width: 8),
Icon(
LucideIcons.chevronRight,
size: 18,
color: colors.onSurfaceVariant,
),
],
],
),
),
),
);
}
}

View file

@ -0,0 +1,175 @@
import 'dart:math' as math;
import 'package:flutter/rendering.dart';
import 'package:material_ui/material_ui.dart';
/// A row of `leading` (tabs, a title) and, at its end, the first of
/// `levels` that leaves `leading` all the width it would take: the levels
/// are the same tools from the widest (every button with its label) to
/// the narrowest (all of them in a ⋮ menu), so where room runs short the
/// tools fold one by one rather than cutting `leading` off. `leading`
/// takes what the level chosen leaves, its natural width at most; the
/// level stands at the row's end. Only the level chosen shows; the others
/// are kept, unseen and untouchable, so their state stays.
// Not const: its children are a list made of the leading child and the
// levels.
// ignore: prefer_const_constructors_in_immutables
class CollapsingRow({
required Widget leading,
required List<Widget> levels,
super.key,
}) extends MultiChildRenderObjectWidget {
this
: assert(levels.isNotEmpty, 'There is always a level to choose'),
super(children: [leading, ...levels]);
@override
RenderObject createRenderObject(BuildContext context) =>
RenderCollapsingRow();
}
/// Lays out a [CollapsingRow]: its first child the leading one, the rest
/// the levels, widest first.
class RenderCollapsingRow()
extends RenderBox
with
ContainerRenderObjectMixin<RenderBox, _RowParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, _RowParentData> {
/// The level shown, by its index among the levels.
@visibleForTesting
int chosen = 0;
@override
void setupParentData(RenderBox child) {
if (child.parentData is! _RowParentData) {
child.parentData = _RowParentData();
}
}
List<RenderBox> get _children => getChildrenAsList();
RenderBox get _leading => _children.first;
List<RenderBox> get _levels => _children.sublist(1);
/// The widest level leaving the leading child its natural width in
/// [width]; the narrowest when none does.
int _choose(double width) {
final need = _leading.getMaxIntrinsicWidth(double.infinity);
final levels = _levels;
for (final (index, level) in levels.indexed) {
if (need + level.getMaxIntrinsicWidth(double.infinity) <= width) {
return index;
}
}
return levels.length - 1;
}
@override
double computeMinIntrinsicWidth(double height) =>
_levels.last.getMinIntrinsicWidth(height);
@override
double computeMaxIntrinsicWidth(double height) =>
_leading.getMaxIntrinsicWidth(height) +
_levels.first.getMaxIntrinsicWidth(height);
@override
double computeMinIntrinsicHeight(double width) => _children.fold(
0,
(most, child) => math.max(most, child.getMinIntrinsicHeight(width)),
);
@override
double computeMaxIntrinsicHeight(double width) => _children.fold(
0,
(most, child) => math.max(most, child.getMaxIntrinsicHeight(width)),
);
@override
Size computeDryLayout(BoxConstraints constraints) {
final width = constraints.maxWidth;
final level = _levels[_choose(width)].getDryLayout(
BoxConstraints(maxHeight: constraints.maxHeight),
);
final leading = _leading.getDryLayout(
BoxConstraints(
maxWidth: math.max(0, width - level.width),
maxHeight: constraints.maxHeight,
),
);
return constraints.constrain(
Size(width, math.max(level.height, leading.height)),
);
}
@override
void performLayout() {
final width = constraints.maxWidth;
chosen = _choose(width);
final levels = _levels;
for (final (index, level) in levels.indexed) {
level.layout(
index == chosen
? BoxConstraints(maxHeight: constraints.maxHeight)
: BoxConstraints.tight(Size.zero),
parentUsesSize: true,
);
}
final level = levels[chosen];
final leading = _leading
..layout(
BoxConstraints(
maxWidth: math.max(0, width - level.size.width),
maxHeight: constraints.maxHeight,
),
parentUsesSize: true,
);
size = constraints.constrain(
Size(width, math.max(level.size.height, leading.size.height)),
);
(leading.parentData! as _RowParentData).offset = Offset(
0,
(size.height - leading.size.height) / 2,
);
(level.parentData! as _RowParentData).offset = Offset(
size.width - level.size.width,
(size.height - level.size.height) / 2,
);
}
/// The children that show: the leading one and the level chosen.
Iterable<RenderBox> get _shown => [_leading, _levels[chosen]];
@override
void paint(PaintingContext context, Offset offset) {
for (final child in _shown) {
context.paintChild(
child,
(child.parentData! as _RowParentData).offset + offset,
);
}
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
for (final child in _shown.toList().reversed) {
final offset = (child.parentData! as _RowParentData).offset;
if (result.addWithPaintOffset(
offset: offset,
position: position,
hitTest: (result, transformed) =>
child.hitTest(result, position: transformed),
)) {
return true;
}
}
return false;
}
@override
void visitChildrenForSemantics(RenderObjectVisitor visitor) =>
_shown.forEach(visitor);
}
class _RowParentData() extends ContainerBoxParentData<RenderBox>;

View file

@ -0,0 +1,31 @@
import 'package:halkyon_ui/l10n/halkyon_ui_localizations.dart';
import 'package:material_ui/material_ui.dart';
/// Asks before something that can't be taken back: true only when the person
/// confirms; cancelling or dismissing the dialog is false.
Future<bool> confirmDestructiveAction(
BuildContext context, {
required String title,
required String message,
required String confirmLabel,
}) async {
final l10n = HalkyonUiLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(l10n.cancel),
),
FilledButton.tonal(
onPressed: () => Navigator.of(context).pop(true),
child: Text(confirmLabel),
),
],
),
);
return confirmed ?? false;
}

View file

@ -0,0 +1,21 @@
import 'package:material_ui/material_ui.dart';
/// How many something holds, in a small box beside its name: a tab, a
/// section, a screen's title.
class const CountBadge({required final int count, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
child: Text('$count', style: theme.textTheme.labelMedium),
),
);
}
}

View file

@ -0,0 +1,180 @@
import 'dart:math' as math;
import 'package:flutter/services.dart';
import 'package:halkyon_ui/l10n/halkyon_ui_localizations.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// A number between a minus and a plus, the width of what holds it, in an
/// outlined box: the number bold ([style], body text by default), [suffix]
/// (" / day") lighter after it. A null action can't go further.
///
/// With [onTyped], tapping the number lets one type another in its place,
/// such as 12 between steps of 5 ([stepUp], [stepDown]); it is handed over
/// once confirmed or left, if it is a number from 1.
class const CountStepper({
required final String value,
required final VoidCallback? onFewer,
required final VoidCallback? onMore,
/// Said after the number, lighter.
final String suffix = '',
/// The number's style, before it is made bold.
final TextStyle? style,
/// The number typed, from 1; none to type without.
final ValueChanged<int>? onTyped,
/// The number to start typing over: [value] as a number.
final int? number,
super.key,
}) extends StatefulWidget {
/// The most digits a number typed has.
static const maxDigits = 4;
@override
State<CountStepper> createState() => _CountStepperState();
}
class _CountStepperState() extends State<CountStepper> {
var _typing = false;
final _field = TextEditingController();
final _focus = FocusNode();
@override
void initState() {
super.initState();
_focus.addListener(() {
if (!_focus.hasFocus && _typing) {
_confirm();
}
});
}
@override
void dispose() {
_field.dispose();
_focus.dispose();
super.dispose();
}
void _type() {
_field.text = '${widget.number ?? ''}';
_field.selection = TextSelection(
baseOffset: 0,
extentOffset: _field.text.length,
);
setState(() => _typing = true);
_focus.requestFocus();
}
void _confirm() {
final typed = int.tryParse(_field.text);
setState(() => _typing = false);
if (typed != null && typed >= 1) {
widget.onTyped?.call(typed);
}
}
@override
Widget build(BuildContext context) {
final l10n = HalkyonUiLocalizations.of(context);
final theme = Theme.of(context);
final colors = theme.colorScheme;
final numberStyle = (widget.style ?? theme.textTheme.bodyLarge)?.copyWith(
color: colors.onSurface,
fontWeight: FontWeight.w600,
);
final shown = Text.rich(
TextSpan(
text: widget.value,
children: [
if (widget.suffix.isNotEmpty)
TextSpan(
text: widget.suffix,
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
fontWeight: FontWeight.w400,
),
),
],
),
textAlign: TextAlign.center,
style: numberStyle,
);
final typable = widget.onTyped != null;
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colors.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
IconButton(
tooltip: l10n.decrease,
onPressed: _typing ? null : widget.onFewer,
icon: const Icon(LucideIcons.minus),
),
Expanded(
child: _typing
? TextField(
controller: _field,
focusNode: _focus,
textAlign: TextAlign.center,
style: numberStyle,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.done,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(
CountStepper.maxDigits,
),
],
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
hintText: l10n.typeANumber,
),
onSubmitted: (_) => _focus.unfocus(),
)
: typable
? Tooltip(
message: l10n.typeANumber,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: _type,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: shown,
),
),
)
: shown,
),
IconButton(
tooltip: l10n.increase,
onPressed: _typing ? null : widget.onMore,
icon: const Icon(LucideIcons.plus),
),
],
),
),
);
}
}
/// [value] up to the next multiple of [step], at most [max]: 12 to 15 in
/// steps of 5.
int stepUp(int value, int step, {required int max}) =>
math.min(max, (value ~/ step + 1) * step);
/// [value] down to the multiple of [step] below it, never below the first
/// step: 12 to 10, 15 to 10 in steps of 5. Smaller numbers are typed.
int stepDown(int value, int step) =>
math.max(step, ((value - 1) ~/ step) * step);
/// Whether [value] can step down: it is above the first step.
bool canStepDown(int value, int step) => value > step;

View file

@ -0,0 +1,44 @@
import 'package:material_ui/material_ui.dart';
/// What a list shows while it holds nothing yet, every list alike: an
/// [icon] for what it lists, a [title] saying there is none yet,
/// and a [hint] saying how to add some.
class const EmptyListMessage({
/// What the list holds, e.g. a folder for files.
required final IconData icon,
/// That there is nothing yet, e.g. "No files yet".
required final String title,
/// How to add something, naming the menus to go through.
required final String hint,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 40, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(height: 12),
Text(
title,
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
hint,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}

View file

@ -0,0 +1,53 @@
import 'package:halkyon_ui/l10n/halkyon_ui_localizations.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// [decoration] marked as a required field's, as every required field is
/// (in place of an asterisk): while [missing], nothing
/// but spaces written, its outline (or its line) stronger, in the primary
/// colour, and "Required" below it, before its own helper text; once
/// written, its own helper text, or an empty line in its place, so nothing
/// below moves.
InputDecoration requiredDecoration(
BuildContext context,
InputDecoration decoration, {
required bool missing,
}) {
final own = decoration.helperText;
if (!missing) {
return decoration.copyWith(helperText: own ?? ' ');
}
final theme = Theme.of(context);
final required = HalkyonUiLocalizations.of(context).requiredField;
final side = BorderSide(color: theme.colorScheme.primary, width: 2);
return decoration.copyWith(
enabledBorder: switch (decoration.border ??
theme.inputDecorationTheme.border) {
final OutlineInputBorder outline => outline.copyWith(borderSide: side),
_ => UnderlineInputBorder(borderSide: side),
},
helperText: own == null ? required : '$required · $own',
helperStyle: (decoration.helperStyle ?? theme.textTheme.bodySmall)
?.copyWith(color: theme.colorScheme.primary),
);
}
/// A question mark at a field's end that explains it: [message] shows on a
/// tap (or hovering) and stays for a while to be read.
class const FieldInfoButton(final String message, {super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) => Tooltip(
message: message,
triggerMode: TooltipTriggerMode.tap,
showDuration: const Duration(seconds: 8),
child: Padding(
padding: const EdgeInsets.all(8),
child: Icon(
LucideIcons.circleQuestionMark,
size: 18,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}

View file

@ -0,0 +1,59 @@
import 'dart:math' as math;
import 'package:material_ui/material_ui.dart';
/// A navigation bar detached from the window's bottom edge, as iOS 26's tab
/// bar floats: [bar] in a rounded bar [sideMargin] in from the sides and
/// [lift] above the system's own bar, lifted as the navigation bar is
/// (elevation level 2). Material 3 has no floating navigation bar for
/// phones (only in its guidelines for headsets); this one keeps the
/// Expressive bar's height and colours. Around it shows the scaffold's
/// background, which the app's shell takes from the screen in view.
class const FloatingNavigationBar({
/// The bar: a [NavigationBar] without a background or elevation of its
/// own.
required final Widget bar,
super.key,
}) extends StatelessWidget {
/// How far in from the window's sides the bar stands.
static const sideMargin = 16.0;
/// How far above the system's own bar (a phone's gesture bar or buttons)
/// the bar floats.
static const lift = 8.0;
/// How much of the window's foot the bar covers beyond the system's own
/// bar, for what [context] shows beneath it; none without it. A floating
/// button keeps clear of the system's bar by itself, but not of this.
static double coverOf(BuildContext context) => math.max(
0,
MediaQuery.paddingOf(context).bottom -
MediaQuery.viewPaddingOf(context).bottom,
);
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Padding(
padding: EdgeInsets.fromLTRB(
sideMargin,
0,
sideMargin,
MediaQuery.paddingOf(context).bottom + lift,
),
child: Material(
color: scheme.surfaceContainer,
elevation: 3,
shape: const StadiumBorder(),
clipBehavior: Clip.antiAlias,
// The bar's own safe area would keep it clear of the system's bar
// a second time.
child: MediaQuery.removePadding(
context: context,
removeBottom: true,
child: bar,
),
),
);
}
}

View file

@ -0,0 +1,98 @@
import 'package:halkyon_ui/l10n/halkyon_ui_localizations.dart';
import 'package:halkyon_ui/src/widget/app_loading_indicator.dart';
import 'package:material_ui/material_ui.dart';
/// Builds a screen for the item a route's [id] names, found in a live list of
/// items: a spinner until the first list arrives, and a "no longer exists"
/// screen while the list has no such item (deleted, perhaps on another
/// device), with [wayBack] below it if given.
///
/// A screen that closes as its item goes, having deleted it, keeps showing
/// the item on its way out.
class const FoundById<T extends Object>({
/// The id from the route.
required final String id,
/// The live list to look in; subscribed to once.
required final Stream<List<T>> Function() items,
/// An item's id.
required final String Function(T item) idOf,
/// The screen for the item found, given the whole list too.
required final Widget Function(T item, List<T> items) builder,
/// What the "no longer exists" screen's button says, and where it leads.
final ({String label, VoidCallback onPressed})? wayBack,
super.key,
}) extends StatefulWidget {
@override
State<FoundById<T>> createState() => _FoundByIdState<T>();
}
class _FoundByIdState<T extends Object>() extends State<FoundById<T>> {
late final Stream<List<T>> _items = widget.items();
/// The item as last found, and the list it was in.
T? _found;
List<T> _foundIn = const [];
@override
Widget build(BuildContext context) => StreamBuilder<List<T>>(
stream: _items,
builder: (context, snapshot) {
final items = snapshot.data;
if (items == null) {
return Scaffold(
appBar: AppBar(),
body: const Center(child: AppLoadingIndicator()),
);
}
for (final item in items) {
if (widget.idOf(item) == widget.id) {
_found = item;
_foundIn = items;
return widget.builder(item, items);
}
}
// A route stops being active as soon as it is popped, while its
// closing transition still shows it.
final found = _found;
if (found != null && !(ModalRoute.of(context)?.isActive ?? true)) {
return widget.builder(found, _foundIn);
}
return _Gone(wayBack: widget.wayBack);
},
);
}
class const _Gone({
required final ({String label, VoidCallback onPressed})? wayBack,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = HalkyonUiLocalizations.of(context);
final way = wayBack;
return Scaffold(
appBar: AppBar(),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.gone, textAlign: TextAlign.center),
if (way != null) ...[
const SizedBox(height: 12),
FilledButton(
onPressed: way.onPressed,
child: Text(way.label),
),
],
],
),
),
),
);
}
}

View file

@ -0,0 +1,46 @@
import 'package:material_ui/material_ui.dart';
/// Coming back to the app with a text field focused brings its keyboard
/// back, as other apps on Android do.
///
/// Flutter (checked with v3.47.5) gives the window back its keyboard's
/// height after the app was left while the keyboard was moving, though no
/// keyboard shows: the screen stays pushed up, as if it were there
/// ([flutter#191156](https://github.com/flutter/flutter/issues/191156),
/// open). With the keyboard back, the height is the keyboard's again.
/// Remove this once that is fixed.
class const KeyboardBackOnResume({required final Widget child, super.key})
extends StatefulWidget {
@override
State<KeyboardBackOnResume> createState() => _KeyboardBackOnResumeState();
}
class _KeyboardBackOnResumeState() extends State<KeyboardBackOnResume> {
late final AppLifecycleListener _lifecycle;
@override
void initState() {
super.initState();
_lifecycle = AppLifecycleListener(onResume: _resumed);
}
@override
void dispose() {
_lifecycle.dispose();
super.dispose();
}
void _resumed() {
// Once the window is back, when the keyboard may show.
WidgetsBinding.instance.addPostFrameCallback((_) {
final field = FocusManager.instance.primaryFocus?.context
?.findAncestorStateOfType<EditableTextState>();
if (field != null && field.mounted && field.widget.focusNode.hasFocus) {
field.requestKeyboard();
}
});
}
@override
Widget build(BuildContext context) => widget.child;
}

Some files were not shown because too many files have changed in this diff Show more