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:
commit
80fa7b9628
44 changed files with 6762 additions and 0 deletions
44
packages/halkyon_ui/lib/halkyon_ui.dart
Normal file
44
packages/halkyon_ui/lib/halkyon_ui.dart
Normal 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';
|
||||
21
packages/halkyon_ui/lib/l10n/halkyon_ui_de.arb
Normal file
21
packages/halkyon_ui/lib/l10n/halkyon_ui_de.arb
Normal 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}"
|
||||
}
|
||||
42
packages/halkyon_ui/lib/l10n/halkyon_ui_en.arb
Normal file
42
packages/halkyon_ui/lib/l10n/halkyon_ui_en.arb
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
packages/halkyon_ui/lib/l10n/halkyon_ui_en_GB.arb
Normal file
3
packages/halkyon_ui/lib/l10n/halkyon_ui_en_GB.arb
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"@@locale": "en_GB"
|
||||
}
|
||||
5
packages/halkyon_ui/lib/l10n/halkyon_ui_en_US.arb
Normal file
5
packages/halkyon_ui/lib/l10n/halkyon_ui_en_US.arb
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"@@locale": "en_US",
|
||||
|
||||
"colorPickerLabel": "Color: {color}"
|
||||
}
|
||||
257
packages/halkyon_ui/lib/l10n/halkyon_ui_localizations.dart
Normal file
257
packages/halkyon_ui/lib/l10n/halkyon_ui_localizations.dart
Normal 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.',
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
108
packages/halkyon_ui/lib/l10n/halkyon_ui_localizations_en.dart
Normal file
108
packages/halkyon_ui/lib/l10n/halkyon_ui_localizations_en.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
3
packages/halkyon_ui/lib/src/layout/readable_width.dart
Normal file
3
packages/halkyon_ui/lib/src/layout/readable_width.dart
Normal 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;
|
||||
41
packages/halkyon_ui/lib/src/layout/window_size.dart
Normal file
41
packages/halkyon_ui/lib/src/layout/window_size.dart
Normal 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);
|
||||
}
|
||||
77
packages/halkyon_ui/lib/src/theme/app_fonts.dart
Normal file
77
packages/halkyon_ui/lib/src/theme/app_fonts.dart
Normal 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',
|
||||
),
|
||||
);
|
||||
});
|
||||
201
packages/halkyon_ui/lib/src/theme/app_theme.dart
Normal file
201
packages/halkyon_ui/lib/src/theme/app_theme.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
13
packages/halkyon_ui/lib/src/theme/brand.dart
Normal file
13
packages/halkyon_ui/lib/src/theme/brand.dart
Normal 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);
|
||||
}
|
||||
58
packages/halkyon_ui/lib/src/theme/palette_color.dart
Normal file
58
packages/halkyon_ui/lib/src/theme/palette_color.dart
Normal 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);
|
||||
73
packages/halkyon_ui/lib/src/theme/theme_extras.dart
Normal file
73
packages/halkyon_ui/lib/src/theme/theme_extras.dart
Normal 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>()!;
|
||||
}
|
||||
392
packages/halkyon_ui/lib/src/widget/adaptive_sheet.dart
Normal file
392
packages/halkyon_ui/lib/src/widget/adaptive_sheet.dart
Normal 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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
154
packages/halkyon_ui/lib/src/widget/app_loading_indicator.dart
Normal file
154
packages/halkyon_ui/lib/src/widget/app_loading_indicator.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
33
packages/halkyon_ui/lib/src/widget/brand_wordmark.dart
Normal file
33
packages/halkyon_ui/lib/src/widget/brand_wordmark.dart
Normal 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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
79
packages/halkyon_ui/lib/src/widget/chevron_field.dart
Normal file
79
packages/halkyon_ui/lib/src/widget/chevron_field.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
175
packages/halkyon_ui/lib/src/widget/collapsing_row.dart
Normal file
175
packages/halkyon_ui/lib/src/widget/collapsing_row.dart
Normal 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
Loading…
Reference in a new issue