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
44 changed files with 6762 additions and 0 deletions

View file

@ -0,0 +1,44 @@
/// Halkyon's look, shared by its apps: the theme around cobalt and copper
/// (`appTheme`), the bundled Noto Sans (`appFontFamily`), Material 3's
/// window sizes (`WindowSize`), and the widgets every app's screens are
/// built of, with their texts in English and German
/// (`HalkyonUiLocalizations`, whose delegate an app adds to its own).
library;
export 'l10n/halkyon_ui_localizations.dart';
export 'src/layout/readable_width.dart';
export 'src/layout/window_size.dart';
export 'src/theme/app_fonts.dart';
export 'src/theme/app_theme.dart';
export 'src/theme/brand.dart';
export 'src/theme/palette_color.dart';
export 'src/theme/theme_extras.dart';
export 'src/widget/adaptive_sheet.dart';
export 'src/widget/app_loading_indicator.dart';
export 'src/widget/brand_wordmark.dart';
export 'src/widget/chevron_field.dart';
export 'src/widget/collapsing_row.dart';
export 'src/widget/confirm_dialog.dart';
export 'src/widget/count_badge.dart';
export 'src/widget/count_stepper.dart';
export 'src/widget/empty_list_message.dart';
export 'src/widget/field_label.dart';
export 'src/widget/floating_navigation_bar.dart';
export 'src/widget/found_by_id.dart';
export 'src/widget/keyboard_back_on_resume.dart';
export 'src/widget/launch_screen.dart';
export 'src/widget/lift_while_dragged.dart';
export 'src/widget/more_info_sheet.dart';
export 'src/widget/palette_color_picker.dart';
export 'src/widget/reorder_grip.dart';
export 'src/widget/reorder_toggle.dart';
export 'src/widget/screen_tint.dart';
export 'src/widget/scroll_hiding_fab.dart';
export 'src/widget/section_card.dart';
export 'src/widget/sliver_centered.dart';
export 'src/widget/tab_visibility.dart';
export 'src/widget/text_limits.dart';
export 'src/widget/tinted_frame.dart';
export 'src/widget/title_with_kicker.dart';
export 'src/widget/top_gradient.dart';
export 'src/widget/undo_snack_bar.dart';

View file

@ -0,0 +1,21 @@
{
"@@locale": "de",
"brand": "Halkyon",
"close": "Schließen",
"cancel": "Abbrechen",
"undo": "Rückgängig",
"dragToReorder": "Zum Umsortieren ziehen",
"reorder": "Umsortieren",
"reorderDone": "Fertig",
"requiredField": "Pflichtfeld",
"decrease": "Verringern",
"increase": "Erhöhen",
"typeANumber": "Zahl eingeben",
"gone": "Das gibt es nicht mehr. Vielleicht wurde es auf einem anderen Gerät gelöscht.",
"moreInfo": "Weitere Informationen",
"copiedToClipboard": "In die Zwischenablage kopiert",
"copy": "Kopieren",
"paletteColorName": "{name, select, carrot{Karotte} cerise{Kirschrot} cobalt{Kobalt} copper{Kupfer} enzian{Enzian} fern{Farn} forest{Wald} ocean{Ozean} olive{Olive} pacific{Pazifik} pickle{Essiggurke} pine{Kiefer} pink{Rosa} plum{Pflaume} purple{Lila} reef{Riff} sahara{Sahara} slateblue{Schieferblau} soil{Erde} strawberry{Erdbeere} other{{name}}}",
"colorPickerLabel": "Farbe: {color}"
}

View file

@ -0,0 +1,42 @@
{
"@@locale": "en",
"brand": "Halkyon",
"@brand": {
"description": "The brand's word, above an app's own in its wordmark (\"Halkyon\" over \"Learn\")."
},
"close": "Close",
"cancel": "Cancel",
"undo": "Undo",
"dragToReorder": "Drag to reorder",
"reorder": "Reorder",
"reorderDone": "Done",
"requiredField": "Required",
"@requiredField": {
"description": "Below a required field while it is empty."
},
"decrease": "Decrease",
"increase": "Increase",
"typeANumber": "Type a number",
"gone": "This no longer exists. It may have been deleted on another device.",
"moreInfo": "More info",
"copiedToClipboard": "Copied to clipboard",
"copy": "Copy",
"paletteColorName": "{name, select, carrot{Carrot} cerise{Cerise} cobalt{Cobalt} copper{Copper} enzian{Enzian} fern{Fern} forest{Forest} ocean{Ocean} olive{Olive} pacific{Pacific} pickle{Pickle} pine{Pine} pink{Pink} plum{Plum} purple{Purple} reef{Reef} sahara{Sahara} slateblue{Slate blue} soil{Soil} strawberry{Strawberry} other{{name}}}",
"@paletteColorName": {
"description": "A colour of the palette content can take, by its stored name.",
"placeholders": {
"name": {
"type": "String"
}
}
},
"colorPickerLabel": "Colour: {color}",
"@colorPickerLabel": {
"placeholders": {
"color": {
"type": "String"
}
}
}
}

View file

@ -0,0 +1,3 @@
{
"@@locale": "en_GB"
}

View file

@ -0,0 +1,5 @@
{
"@@locale": "en_US",
"colorPickerLabel": "Color: {color}"
}

View file

@ -0,0 +1,257 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/intl.dart' as intl;
import 'halkyon_ui_localizations_de.dart';
import 'halkyon_ui_localizations_en.dart';
// ignore_for_file: type=lint
/// Callers can lookup localized strings with an instance of HalkyonUiLocalizations
/// returned by `HalkyonUiLocalizations.of(context)`.
///
/// Applications need to include `HalkyonUiLocalizations.delegate()` in their app's
/// `localizationDelegates` list, and the locales they support in the app's
/// `supportedLocales` list. For example:
///
/// ```dart
/// import 'l10n/halkyon_ui_localizations.dart';
///
/// return MaterialApp(
/// localizationsDelegates: HalkyonUiLocalizations.localizationsDelegates,
/// supportedLocales: HalkyonUiLocalizations.supportedLocales,
/// home: MyApplicationHome(),
/// );
/// ```
///
/// ## Update pubspec.yaml
///
/// Please make sure to update your pubspec.yaml to include the following
/// packages:
///
/// ```yaml
/// dependencies:
/// # Internationalization support.
/// flutter_localizations:
/// sdk: flutter
/// intl: any # Use the pinned version from flutter_localizations
///
/// # Rest of dependencies
/// ```
///
/// ## iOS Applications
///
/// iOS applications define key application metadata, including supported
/// locales, in an Info.plist file that is built into the application bundle.
/// To configure the locales supported by your app, you’ll need to edit this
/// file.
///
/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file.
/// Then, in the Project Navigator, open the Info.plist file under the Runner
/// project’s Runner folder.
///
/// Next, select the Information Property List item, select Add Item from the
/// Editor menu, then select Localizations from the pop-up menu.
///
/// Select and expand the newly-created Localizations item then, for each
/// locale your application supports, add a new item and select the locale
/// you wish to add from the pop-up menu in the Value field. This list should
/// be consistent with the languages listed in the HalkyonUiLocalizations.supportedLocales
/// property.
abstract class HalkyonUiLocalizations {
HalkyonUiLocalizations(String locale)
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
final String localeName;
static HalkyonUiLocalizations of(BuildContext context) {
return Localizations.of<HalkyonUiLocalizations>(
context,
HalkyonUiLocalizations,
)!;
}
static const LocalizationsDelegate<HalkyonUiLocalizations> delegate =
_HalkyonUiLocalizationsDelegate();
/// A list of this localizations delegate along with the default localizations
/// delegates.
///
/// Returns a list of localizations delegates containing this delegate along with
/// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
/// and GlobalWidgetsLocalizations.delegate.
///
/// Additional delegates can be added by appending to this list in
/// MaterialApp. This list does not have to be used at all if a custom list
/// of delegates is preferred or required.
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
<LocalizationsDelegate<dynamic>>[
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
];
/// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[
Locale('de'),
Locale('en'),
Locale('en', 'GB'),
Locale('en', 'US'),
];
/// The brand's word, above an app's own in its wordmark ("Halkyon" over "Learn").
///
/// In en, this message translates to:
/// **'Halkyon'**
String get brand;
/// No description provided for @close.
///
/// In en, this message translates to:
/// **'Close'**
String get close;
/// No description provided for @cancel.
///
/// In en, this message translates to:
/// **'Cancel'**
String get cancel;
/// No description provided for @undo.
///
/// In en, this message translates to:
/// **'Undo'**
String get undo;
/// No description provided for @dragToReorder.
///
/// In en, this message translates to:
/// **'Drag to reorder'**
String get dragToReorder;
/// No description provided for @reorder.
///
/// In en, this message translates to:
/// **'Reorder'**
String get reorder;
/// No description provided for @reorderDone.
///
/// In en, this message translates to:
/// **'Done'**
String get reorderDone;
/// Below a required field while it is empty.
///
/// In en, this message translates to:
/// **'Required'**
String get requiredField;
/// No description provided for @decrease.
///
/// In en, this message translates to:
/// **'Decrease'**
String get decrease;
/// No description provided for @increase.
///
/// In en, this message translates to:
/// **'Increase'**
String get increase;
/// No description provided for @typeANumber.
///
/// In en, this message translates to:
/// **'Type a number'**
String get typeANumber;
/// No description provided for @gone.
///
/// In en, this message translates to:
/// **'This no longer exists. It may have been deleted on another device.'**
String get gone;
/// No description provided for @moreInfo.
///
/// In en, this message translates to:
/// **'More info'**
String get moreInfo;
/// No description provided for @copiedToClipboard.
///
/// In en, this message translates to:
/// **'Copied to clipboard'**
String get copiedToClipboard;
/// No description provided for @copy.
///
/// In en, this message translates to:
/// **'Copy'**
String get copy;
/// A colour of the palette content can take, by its stored name.
///
/// In en, this message translates to:
/// **'{name, select, carrot{Carrot} cerise{Cerise} cobalt{Cobalt} copper{Copper} enzian{Enzian} fern{Fern} forest{Forest} ocean{Ocean} olive{Olive} pacific{Pacific} pickle{Pickle} pine{Pine} pink{Pink} plum{Plum} purple{Purple} reef{Reef} sahara{Sahara} slateblue{Slate blue} soil{Soil} strawberry{Strawberry} other{{name}}}'**
String paletteColorName(String name);
/// No description provided for @colorPickerLabel.
///
/// In en, this message translates to:
/// **'Colour: {color}'**
String colorPickerLabel(String color);
}
class _HalkyonUiLocalizationsDelegate
extends LocalizationsDelegate<HalkyonUiLocalizations> {
const _HalkyonUiLocalizationsDelegate();
@override
Future<HalkyonUiLocalizations> load(Locale locale) {
return SynchronousFuture<HalkyonUiLocalizations>(
lookupHalkyonUiLocalizations(locale),
);
}
@override
bool isSupported(Locale locale) =>
<String>['de', 'en'].contains(locale.languageCode);
@override
bool shouldReload(_HalkyonUiLocalizationsDelegate old) => false;
}
HalkyonUiLocalizations lookupHalkyonUiLocalizations(Locale locale) {
// Lookup logic when language+country codes are specified.
switch (locale.languageCode) {
case 'en':
{
switch (locale.countryCode) {
case 'GB':
return HalkyonUiLocalizationsEnGb();
case 'US':
return HalkyonUiLocalizationsEnUs();
}
break;
}
}
// Lookup logic when only language code is specified.
switch (locale.languageCode) {
case 'de':
return HalkyonUiLocalizationsDe();
case 'en':
return HalkyonUiLocalizationsEn();
}
throw FlutterError(
'HalkyonUiLocalizations.delegate failed to load unsupported locale "$locale". This is likely '
'an issue with the localizations generation tool. Please file an issue '
'on GitHub with a reproducible sample app and the gen-l10n configuration '
'that was used.',
);
}

View file

@ -0,0 +1,93 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'halkyon_ui_localizations.dart';
// ignore_for_file: type=lint
/// The translations for German (`de`).
class HalkyonUiLocalizationsDe extends HalkyonUiLocalizations {
HalkyonUiLocalizationsDe([String locale = 'de']) : super(locale);
@override
String get brand => 'Halkyon';
@override
String get close => 'Schließen';
@override
String get cancel => 'Abbrechen';
@override
String get undo => 'Rückgängig';
@override
String get dragToReorder => 'Zum Umsortieren ziehen';
@override
String get reorder => 'Umsortieren';
@override
String get reorderDone => 'Fertig';
@override
String get requiredField => 'Pflichtfeld';
@override
String get decrease => 'Verringern';
@override
String get increase => 'Erhöhen';
@override
String get typeANumber => 'Zahl eingeben';
@override
String get gone =>
'Das gibt es nicht mehr. Vielleicht wurde es auf einem anderen Gerät gelöscht.';
@override
String get moreInfo => 'Weitere Informationen';
@override
String get copiedToClipboard => 'In die Zwischenablage kopiert';
@override
String get copy => 'Kopieren';
@override
String paletteColorName(String name) {
String _temp0 = intl.Intl.selectLogic(
name,
{
'carrot': 'Karotte',
'cerise': 'Kirschrot',
'cobalt': 'Kobalt',
'copper': 'Kupfer',
'enzian': 'Enzian',
'fern': 'Farn',
'forest': 'Wald',
'ocean': 'Ozean',
'olive': 'Olive',
'pacific': 'Pazifik',
'pickle': 'Essiggurke',
'pine': 'Kiefer',
'pink': 'Rosa',
'plum': 'Pflaume',
'purple': 'Lila',
'reef': 'Riff',
'sahara': 'Sahara',
'slateblue': 'Schieferblau',
'soil': 'Erde',
'strawberry': 'Erdbeere',
'other': '$name',
},
);
return '$_temp0';
}
@override
String colorPickerLabel(String color) {
return 'Farbe: $color';
}
}

View file

@ -0,0 +1,108 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'halkyon_ui_localizations.dart';
// ignore_for_file: type=lint
/// The translations for English (`en`).
class HalkyonUiLocalizationsEn extends HalkyonUiLocalizations {
HalkyonUiLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get brand => 'Halkyon';
@override
String get close => 'Close';
@override
String get cancel => 'Cancel';
@override
String get undo => 'Undo';
@override
String get dragToReorder => 'Drag to reorder';
@override
String get reorder => 'Reorder';
@override
String get reorderDone => 'Done';
@override
String get requiredField => 'Required';
@override
String get decrease => 'Decrease';
@override
String get increase => 'Increase';
@override
String get typeANumber => 'Type a number';
@override
String get gone =>
'This no longer exists. It may have been deleted on another device.';
@override
String get moreInfo => 'More info';
@override
String get copiedToClipboard => 'Copied to clipboard';
@override
String get copy => 'Copy';
@override
String paletteColorName(String name) {
String _temp0 = intl.Intl.selectLogic(
name,
{
'carrot': 'Carrot',
'cerise': 'Cerise',
'cobalt': 'Cobalt',
'copper': 'Copper',
'enzian': 'Enzian',
'fern': 'Fern',
'forest': 'Forest',
'ocean': 'Ocean',
'olive': 'Olive',
'pacific': 'Pacific',
'pickle': 'Pickle',
'pine': 'Pine',
'pink': 'Pink',
'plum': 'Plum',
'purple': 'Purple',
'reef': 'Reef',
'sahara': 'Sahara',
'slateblue': 'Slate blue',
'soil': 'Soil',
'strawberry': 'Strawberry',
'other': '$name',
},
);
return '$_temp0';
}
@override
String colorPickerLabel(String color) {
return 'Colour: $color';
}
}
/// The translations for English, as used in the United Kingdom (`en_GB`).
class HalkyonUiLocalizationsEnGb extends HalkyonUiLocalizationsEn {
HalkyonUiLocalizationsEnGb() : super('en_GB');
}
/// The translations for English, as used in the United States (`en_US`).
class HalkyonUiLocalizationsEnUs extends HalkyonUiLocalizationsEn {
HalkyonUiLocalizationsEnUs() : super('en_US');
@override
String colorPickerLabel(String color) {
return 'Color: $color';
}
}

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>;

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