diff --git a/client_app/assets/fonts/BricolageGrotesque-Variable.ttf b/client_app/assets/fonts/BricolageGrotesque-Variable.ttf new file mode 100644 index 0000000..9a4ca27 Binary files /dev/null and b/client_app/assets/fonts/BricolageGrotesque-Variable.ttf differ diff --git a/client_app/assets/fonts/JetBrainsMono-Variable.ttf b/client_app/assets/fonts/JetBrainsMono-Variable.ttf new file mode 100644 index 0000000..aa310be Binary files /dev/null and b/client_app/assets/fonts/JetBrainsMono-Variable.ttf differ diff --git a/client_app/assets/fonts/Poppins-Bold.ttf b/client_app/assets/fonts/Poppins-Bold.ttf new file mode 100644 index 0000000..1982f38 Binary files /dev/null and b/client_app/assets/fonts/Poppins-Bold.ttf differ diff --git a/client_app/assets/fonts/Poppins-Medium.ttf b/client_app/assets/fonts/Poppins-Medium.ttf new file mode 100644 index 0000000..a590f5c Binary files /dev/null and b/client_app/assets/fonts/Poppins-Medium.ttf differ diff --git a/client_app/assets/fonts/Poppins-Regular.ttf b/client_app/assets/fonts/Poppins-Regular.ttf new file mode 100644 index 0000000..0bda228 Binary files /dev/null and b/client_app/assets/fonts/Poppins-Regular.ttf differ diff --git a/client_app/assets/fonts/Poppins-SemiBold.ttf b/client_app/assets/fonts/Poppins-SemiBold.ttf new file mode 100644 index 0000000..c30ad10 Binary files /dev/null and b/client_app/assets/fonts/Poppins-SemiBold.ttf differ diff --git a/client_app/assets/fonts/README.md b/client_app/assets/fonts/README.md new file mode 100644 index 0000000..5421108 --- /dev/null +++ b/client_app/assets/fonts/README.md @@ -0,0 +1,15 @@ +# HaloBestie font assets + +Stage 0 design-system fonts. All licensed under the SIL Open Font License. + +| File | Source | +|-----------------------------------|--------| +| `BricolageGrotesque-Variable.ttf` | https://github.com/google/fonts/tree/main/ofl/bricolagegrotesque | +| `Poppins-Regular.ttf` | https://github.com/google/fonts/tree/main/ofl/poppins | +| `Poppins-Medium.ttf` | https://github.com/google/fonts/tree/main/ofl/poppins | +| `Poppins-SemiBold.ttf` | https://github.com/google/fonts/tree/main/ofl/poppins | +| `Poppins-Bold.ttf` | https://github.com/google/fonts/tree/main/ofl/poppins | +| `JetBrainsMono-Variable.ttf` | https://github.com/google/fonts/tree/main/ofl/jetbrainsmono | + +Wired into `client_app/pubspec.yaml` and consumed via `HaloTokens.fontDisplay`, +`fontBody`, `fontMono` in `client_app/lib/core/theme/halo_tokens.dart`. diff --git a/client_app/lib/core/theme/_preview.dart b/client_app/lib/core/theme/_preview.dart new file mode 100644 index 0000000..6cb52b9 --- /dev/null +++ b/client_app/lib/core/theme/_preview.dart @@ -0,0 +1,289 @@ +import 'package:flutter/material.dart'; +import 'halo_tokens.dart'; +import 'widgets/widgets.dart'; + +const bool kThemePreviewEnabled = bool.fromEnvironment( + 'THEME_PREVIEW', + defaultValue: false, +); + +class ThemePreviewScreen extends StatefulWidget { + const ThemePreviewScreen({super.key}); + + @override + State createState() => _ThemePreviewScreenState(); +} + +class _ThemePreviewScreenState extends State { + final Set _selectedChips = {'gak nyenyak'}; + bool _disablePrimary = false; + + @override + Widget build(BuildContext context) { + final text = Theme.of(context).textTheme; + return Scaffold( + appBar: AppBar(title: const Text('Halo theme preview')), + body: ListView( + padding: const EdgeInsets.all(HaloSpacing.s20), + children: [ + _section('Typography'), + Text('display large 36/700', style: text.displayLarge), + const SizedBox(height: HaloSpacing.s8), + Text('title large 22/700', style: text.titleLarge), + const SizedBox(height: HaloSpacing.s8), + Text( + 'body medium 15/400 — Poppins', + style: text.bodyMedium, + ), + const SizedBox(height: HaloSpacing.s8), + Text( + 'label small 10/600 — caption tracking', + style: text.labelSmall, + ), + _divider(), + + _section('Color tokens'), + const Wrap( + spacing: HaloSpacing.s8, + runSpacing: HaloSpacing.s8, + children: [ + _Swatch('brand', HaloTokens.brand), + _Swatch('brandDark', HaloTokens.brandDark), + _Swatch('brandSoft', HaloTokens.brandSoft), + _Swatch('accent', HaloTokens.accent), + _Swatch('mint', HaloTokens.mint), + _Swatch('lilac', HaloTokens.lilac), + _Swatch('success', HaloTokens.success), + _Swatch('danger', HaloTokens.danger), + _Swatch('ink', HaloTokens.ink, label: Colors.white), + _Swatch('inkSoft', HaloTokens.inkSoft, label: Colors.white), + ], + ), + _divider(), + + _section('HaloButton'), + Wrap( + spacing: HaloSpacing.s8, + runSpacing: HaloSpacing.s8, + children: [ + HaloButton( + label: 'primary md', + onPressed: _disablePrimary ? null : () {}, + ), + HaloButton( + label: 'secondary', + variant: HaloButtonVariant.secondary, + onPressed: () {}, + ), + HaloButton( + label: 'ghost', + variant: HaloButtonVariant.ghost, + onPressed: () {}, + ), + HaloButton( + label: 'small', + size: HaloButtonSize.sm, + onPressed: () {}, + ), + HaloButton( + label: 'large with icon', + size: HaloButtonSize.lg, + icon: const Icon(Icons.send_rounded), + onPressed: () {}, + ), + const HaloButton( + label: 'disabled', + onPressed: null, + ), + ], + ), + const SizedBox(height: HaloSpacing.s12), + Row( + children: [ + Switch( + value: _disablePrimary, + onChanged: (v) => setState(() => _disablePrimary = v), + ), + const Text('disable primary'), + ], + ), + _divider(), + + _section('HaloOrb'), + Wrap( + spacing: HaloSpacing.s12, + runSpacing: HaloSpacing.s12, + children: List.generate( + 6, + (i) => HaloOrb(seed: i, label: 'ABCDEF'[i]), + ), + ), + _divider(), + + _section('HaloStepDots'), + for (int c = 1; c <= 4; c++) ...[ + Padding( + padding: const EdgeInsets.only(bottom: HaloSpacing.s8), + child: HaloStepDots(total: 4, current: c), + ), + ], + _divider(), + + _section('HaloChip (ESP-style multi-select)'), + Wrap( + spacing: HaloSpacing.s8, + runSpacing: HaloSpacing.s8, + children: [ + for (final t in const [ + 'gak nyenyak', + 'overthinking', + 'putus', + 'kerjaan', + 'keluarga', + 'sendiri', + ]) + HaloChip( + label: t, + selected: _selectedChips.contains(t), + onTap: () => setState(() { + if (!_selectedChips.add(t)) _selectedChips.remove(t); + }), + ), + ], + ), + _divider(), + + _section('HaloBottomSheet / HaloPopup / HaloSnackbar'), + Wrap( + spacing: HaloSpacing.s8, + runSpacing: HaloSpacing.s8, + children: [ + HaloButton( + label: 'show bottom sheet', + variant: HaloButtonVariant.secondary, + onPressed: () => HaloBottomSheet.show( + context, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('halo bestie', style: text.titleLarge), + const SizedBox(height: HaloSpacing.s8), + Text( + 'mau verif nomor dulu, atau ngobrol anonim?', + style: text.bodyMedium, + ), + const SizedBox(height: HaloSpacing.s24), + HaloButton( + label: 'verif nomor', + fullWidth: true, + onPressed: () => Navigator.of(context).pop(), + ), + const SizedBox(height: HaloSpacing.s8), + HaloButton( + label: 'lanjut anonim', + variant: HaloButtonVariant.ghost, + fullWidth: true, + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + ), + HaloButton( + label: 'show popup', + variant: HaloButtonVariant.secondary, + onPressed: () => HaloPopup.show( + context, + title: 'verif lagi penuh', + body: 'coba lagi nanti, atau lanjut tanpa verif aja.', + icon: const Icon( + Icons.lock_clock_rounded, + size: 40, + color: HaloTokens.brand, + ), + primary: HaloPopupAction( + label: 'lanjut tanpa verif', + onPressed: () {}, + ), + secondary: HaloPopupAction( + label: 'hubungi admin', + onPressed: () {}, + ), + ), + ), + HaloButton( + label: 'show snackbar', + variant: HaloButtonVariant.secondary, + onPressed: () => HaloSnackbar.show( + context, + 'sisa 3 menit lagi ya', + icon: '⏳', + ), + ), + ], + ), + _divider(), + + _section('Input'), + const TextField( + decoration: InputDecoration( + hintText: 'mau dipanggil apa?', + labelText: 'nama panggilan', + ), + ), + const SizedBox(height: HaloSpacing.s48), + ], + ), + ); + } + + Widget _section(String title) => Padding( + padding: const EdgeInsets.only(top: HaloSpacing.s16, bottom: HaloSpacing.s8), + child: Text( + title, + style: const TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 18, + fontWeight: FontWeight.w700, + color: HaloTokens.brandDark, + ), + ), + ); + + Widget _divider() => const Padding( + padding: EdgeInsets.symmetric(vertical: HaloSpacing.s16), + child: Divider(), + ); +} + +class _Swatch extends StatelessWidget { + const _Swatch(this.name, this.color, {this.label = HaloTokens.ink}); + final String name; + final Color color; + final Color label; + + @override + Widget build(BuildContext context) { + return Container( + width: 110, + height: 64, + padding: const EdgeInsets.all(HaloSpacing.s8), + alignment: Alignment.bottomLeft, + decoration: BoxDecoration( + color: color, + borderRadius: HaloRadius.md, + border: Border.all(color: HaloTokens.border), + ), + child: Text( + name, + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 12, + fontWeight: FontWeight.w600, + color: label, + ), + ), + ); + } +} diff --git a/client_app/lib/core/theme/halo_theme.dart b/client_app/lib/core/theme/halo_theme.dart new file mode 100644 index 0000000..edd037b --- /dev/null +++ b/client_app/lib/core/theme/halo_theme.dart @@ -0,0 +1,316 @@ +import 'package:flutter/material.dart'; +import 'halo_tokens.dart'; + +ThemeData haloThemeData() { + final base = ColorScheme.fromSeed( + seedColor: HaloTokens.brand, + brightness: Brightness.light, + ); + + final colorScheme = base.copyWith( + primary: HaloTokens.brand, + onPrimary: Colors.white, + primaryContainer: HaloTokens.brandSoft, + onPrimaryContainer: HaloTokens.brandDark, + secondary: HaloTokens.accent, + onSecondary: HaloTokens.ink, + secondaryContainer: HaloTokens.accentSoft, + onSecondaryContainer: HaloTokens.brandDark, + surface: HaloTokens.surface, + onSurface: HaloTokens.ink, + surfaceContainerHighest: HaloTokens.bg, + error: HaloTokens.danger, + onError: Colors.white, + outline: HaloTokens.border, + ); + + const textTheme = TextTheme( + displayLarge: TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 36, + height: 40 / 36, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + color: HaloTokens.ink, + ), + displayMedium: TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 30, + height: 34 / 30, + fontWeight: FontWeight.w700, + letterSpacing: -0.4, + color: HaloTokens.ink, + ), + displaySmall: TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 26, + height: 30 / 26, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + color: HaloTokens.ink, + ), + headlineMedium: TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 22, + height: 28 / 22, + fontWeight: FontWeight.w700, + color: HaloTokens.ink, + ), + titleLarge: TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 22, + height: 28 / 22, + fontWeight: FontWeight.w700, + color: HaloTokens.ink, + ), + titleMedium: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 18, + height: 24 / 18, + fontWeight: FontWeight.w600, + color: HaloTokens.ink, + ), + titleSmall: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 15, + height: 22 / 15, + fontWeight: FontWeight.w600, + color: HaloTokens.ink, + ), + bodyLarge: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 16, + height: 24 / 16, + fontWeight: FontWeight.w400, + color: HaloTokens.ink, + ), + bodyMedium: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 15, + height: 22 / 15, + fontWeight: FontWeight.w400, + color: HaloTokens.ink, + ), + bodySmall: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 13, + height: 18 / 13, + fontWeight: FontWeight.w500, + color: HaloTokens.inkSoft, + ), + labelLarge: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 15, + height: 22 / 15, + fontWeight: FontWeight.w600, + color: HaloTokens.ink, + ), + labelMedium: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 12, + height: 16 / 12, + fontWeight: FontWeight.w500, + color: HaloTokens.inkSoft, + ), + labelSmall: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 10, + height: 14 / 10, + fontWeight: FontWeight.w600, + letterSpacing: 0.4, + color: HaloTokens.inkMuted, + ), + ); + + return ThemeData( + useMaterial3: true, + colorScheme: colorScheme, + scaffoldBackgroundColor: HaloTokens.bg, + textTheme: textTheme, + fontFamily: HaloTokens.fontBody, + appBarTheme: const AppBarTheme( + backgroundColor: HaloTokens.bg, + foregroundColor: HaloTokens.ink, + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: false, + titleTextStyle: TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 22, + height: 28 / 22, + fontWeight: FontWeight.w700, + color: HaloTokens.ink, + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: HaloTokens.brand, + foregroundColor: Colors.white, + disabledBackgroundColor: HaloTokens.brandSoft, + disabledForegroundColor: HaloTokens.inkMuted, + elevation: 0, + shadowColor: const Color(0x59E17A9D), + padding: const EdgeInsets.symmetric( + horizontal: HaloSpacing.s24, + vertical: HaloSpacing.s16, + ), + shape: const RoundedRectangleBorder(borderRadius: HaloRadius.pill), + textStyle: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: HaloTokens.brandDark, + side: const BorderSide(color: HaloTokens.border), + padding: const EdgeInsets.symmetric( + horizontal: HaloSpacing.s24, + vertical: HaloSpacing.s16, + ), + shape: const RoundedRectangleBorder(borderRadius: HaloRadius.pill), + textStyle: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: HaloTokens.brandDark, + padding: const EdgeInsets.symmetric( + horizontal: HaloSpacing.s16, + vertical: HaloSpacing.s12, + ), + shape: const RoundedRectangleBorder(borderRadius: HaloRadius.pill), + textStyle: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: HaloTokens.surface, + contentPadding: EdgeInsets.symmetric( + horizontal: HaloSpacing.s20, + vertical: HaloSpacing.s20, + ), + constraints: BoxConstraints(minHeight: 64), + hintStyle: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 15, + color: HaloTokens.inkMuted, + ), + labelStyle: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 13, + color: HaloTokens.inkSoft, + ), + border: OutlineInputBorder( + borderRadius: HaloRadius.lg, + borderSide: BorderSide(color: HaloTokens.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: HaloRadius.lg, + borderSide: BorderSide(color: HaloTokens.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: HaloRadius.lg, + borderSide: BorderSide(color: HaloTokens.brand, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: HaloRadius.lg, + borderSide: BorderSide(color: HaloTokens.danger), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: HaloRadius.lg, + borderSide: BorderSide(color: HaloTokens.danger, width: 2), + ), + ), + bottomSheetTheme: const BottomSheetThemeData( + backgroundColor: HaloTokens.surface, + surfaceTintColor: HaloTokens.surface, + modalBackgroundColor: HaloTokens.surface, + modalBarrierColor: Color(0x66000000), + elevation: 0, + modalElevation: 0, + showDragHandle: true, + dragHandleColor: HaloTokens.brandSoft, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(24), + topRight: Radius.circular(24), + ), + ), + clipBehavior: Clip.antiAlias, + ), + dialogTheme: const DialogThemeData( + backgroundColor: HaloTokens.surface, + surfaceTintColor: HaloTokens.surface, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: HaloRadius.xl), + titleTextStyle: TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 22, + fontWeight: FontWeight.w700, + color: HaloTokens.ink, + ), + contentTextStyle: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 15, + height: 22 / 15, + color: HaloTokens.inkSoft, + ), + ), + snackBarTheme: const SnackBarThemeData( + behavior: SnackBarBehavior.floating, + backgroundColor: HaloTokens.ink, + contentTextStyle: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + shape: RoundedRectangleBorder(borderRadius: HaloRadius.pill), + elevation: 4, + insetPadding: EdgeInsets.symmetric( + horizontal: HaloSpacing.s16, + vertical: HaloSpacing.s12, + ), + actionTextColor: HaloTokens.brandSoft, + ), + chipTheme: ChipThemeData( + backgroundColor: HaloTokens.surface, + selectedColor: HaloTokens.brand, + disabledColor: HaloTokens.brandSoft.withValues(alpha: 0.5), + labelStyle: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 14, + fontWeight: FontWeight.w500, + color: HaloTokens.ink, + ), + secondaryLabelStyle: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + padding: const EdgeInsets.symmetric( + horizontal: HaloSpacing.s16, + vertical: HaloSpacing.s8, + ), + side: const BorderSide(color: HaloTokens.border), + shape: const RoundedRectangleBorder(borderRadius: HaloRadius.pill), + ), + dividerTheme: const DividerThemeData( + color: HaloTokens.border, + thickness: 1, + space: 1, + ), + ); +} diff --git a/client_app/lib/core/theme/halo_tokens.dart b/client_app/lib/core/theme/halo_tokens.dart new file mode 100644 index 0000000..7816880 --- /dev/null +++ b/client_app/lib/core/theme/halo_tokens.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; + +/// Design tokens for the HaloBestie warm palette. +/// +/// Mirrors `requirement/Figma/handoff/tokens.json`. Three palettes +/// (warm/calm/playful) exist in the source-of-truth JSON; only `warm` +/// ships in code today — the others are stubbed for phase 5. +/// +/// Naming convention: every token prefixed with `Halo*` and grouped into +/// purpose classes (`HaloTokens` for colors, `HaloSpacing`, `HaloRadius`, +/// `HaloMotion`, `HaloShadows`). +class HaloTokens { + const HaloTokens._(); + + // Warm palette — default. + static const Color bg = Color(0xFFFDF7F4); + static const Color surface = Color(0xFFFFFFFF); + static const Color ink = Color(0xFF2A1820); + static const Color inkSoft = Color(0xFF6B5560); + static const Color inkMuted = Color(0xFF9C8590); + static const Color brand = Color(0xFFE17A9D); + static const Color brandDark = Color(0xFF8C3255); + static const Color brandSoft = Color(0xFFF7E4E9); + static const Color brandSofter = Color(0xFFFBEFF3); + static const Color accent = Color(0xFFF7B26A); + static const Color accentSoft = Color(0xFFFCEAD3); + static const Color mint = Color(0xFFB8DBC8); + static const Color lilac = Color(0xFFD4C5E8); + static const Color success = Color(0xFF5BA67F); + static const Color danger = Color(0xFFD86B6B); + static const Color border = Color(0xFFF0E4E8); + + // Font family names — must match the `family:` entries in pubspec.yaml. + // Falls back to system fonts when the .ttf assets are not bundled. + static const String fontDisplay = 'BricolageGrotesque'; + static const String fontBody = 'Poppins'; + static const String fontMono = 'JetBrainsMono'; + + // TODO: phase5 — calm palette + // static const Color calmBg = Color(0xFFF6F4F8); + // static const Color calmBrand = Color(0xFF9B8BC4); + // ... + + // TODO: phase5 — playful palette + // static const Color playfulBg = Color(0xFFFFF5F8); + // static const Color playfulBrand = Color(0xFFFF69A0); + // ... +} + +class HaloSpacing { + const HaloSpacing._(); + + static const double s0 = 0; + static const double s4 = 4; + static const double s8 = 8; + static const double s12 = 12; + static const double s16 = 16; + static const double s20 = 20; + static const double s24 = 24; + static const double s32 = 32; + static const double s40 = 40; + static const double s48 = 48; + static const double s64 = 64; + static const double s80 = 80; +} + +class HaloRadius { + const HaloRadius._(); + + static const Radius _sm = Radius.circular(8); + static const Radius _md = Radius.circular(12); + static const Radius _lg = Radius.circular(16); + static const Radius _xl = Radius.circular(22); + static const Radius _pill = Radius.circular(9999); + + static const BorderRadius sm = BorderRadius.all(_sm); + static const BorderRadius md = BorderRadius.all(_md); + static const BorderRadius lg = BorderRadius.all(_lg); + static const BorderRadius xl = BorderRadius.all(_xl); + static const BorderRadius pill = BorderRadius.all(_pill); +} + +class HaloMotion { + const HaloMotion._(); + + static const Duration fast = Duration(milliseconds: 180); + static const Duration normal = Duration(milliseconds: 280); + static const Duration slow = Duration(milliseconds: 420); + + static const Cubic ease = Cubic(0.2, 0.8, 0.2, 1); +} + +class HaloShadows { + const HaloShadows._(); + + static const List soft = [ + BoxShadow( + color: Color(0x0A8C3255), + offset: Offset(0, 1), + blurRadius: 2, + ), + BoxShadow( + color: Color(0x0F8C3255), + offset: Offset(0, 8), + blurRadius: 24, + ), + ]; + + static const List card = [ + BoxShadow( + color: Color(0x0D8C3255), + offset: Offset(0, 2), + blurRadius: 6, + ), + BoxShadow( + color: Color(0x1A8C3255), + offset: Offset(0, 18), + blurRadius: 40, + ), + ]; + + static const List button = [ + BoxShadow( + color: Color(0x59E17A9D), + offset: Offset(0, 4), + blurRadius: 14, + ), + ]; +} diff --git a/client_app/lib/core/theme/widgets/halo_bottom_sheet.dart b/client_app/lib/core/theme/widgets/halo_bottom_sheet.dart new file mode 100644 index 0000000..545d99b --- /dev/null +++ b/client_app/lib/core/theme/widgets/halo_bottom_sheet.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import '../halo_tokens.dart'; + +class HaloBottomSheet { + const HaloBottomSheet._(); + + static Future show( + BuildContext context, { + required Widget child, + bool isDismissible = true, + bool enableDrag = true, + bool isScrollControlled = false, + }) { + return showModalBottomSheet( + context: context, + isDismissible: isDismissible, + enableDrag: enableDrag, + isScrollControlled: isScrollControlled, + backgroundColor: HaloTokens.surface, + barrierColor: const Color(0x66000000), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(24), + topRight: Radius.circular(24), + ), + ), + showDragHandle: true, + builder: (ctx) => SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB( + HaloSpacing.s24, + HaloSpacing.s8, + HaloSpacing.s24, + HaloSpacing.s24, + ), + child: child, + ), + ), + ); + } +} diff --git a/client_app/lib/core/theme/widgets/halo_button.dart b/client_app/lib/core/theme/widgets/halo_button.dart new file mode 100644 index 0000000..65c5a9a --- /dev/null +++ b/client_app/lib/core/theme/widgets/halo_button.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; +import '../halo_tokens.dart'; + +enum HaloButtonVariant { primary, secondary, ghost } + +enum HaloButtonSize { sm, md, lg } + +class HaloButton extends StatelessWidget { + const HaloButton({ + super.key, + required this.label, + required this.onPressed, + this.variant = HaloButtonVariant.primary, + this.size = HaloButtonSize.md, + this.icon, + this.fullWidth = false, + }); + + final String label; + final VoidCallback? onPressed; + final HaloButtonVariant variant; + final HaloButtonSize size; + final Widget? icon; + final bool fullWidth; + + @override + Widget build(BuildContext context) { + final disabled = onPressed == null; + final padding = _padding(); + final fontSize = _fontSize(); + const shape = RoundedRectangleBorder(borderRadius: HaloRadius.pill); + final textStyle = TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: fontSize, + fontWeight: FontWeight.w600, + ); + + Widget child = _content(textStyle); + + Widget button; + switch (variant) { + case HaloButtonVariant.primary: + button = Container( + decoration: disabled + ? null + : const BoxDecoration( + borderRadius: HaloRadius.pill, + boxShadow: HaloShadows.button, + ), + child: ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: HaloTokens.brand, + foregroundColor: Colors.white, + disabledBackgroundColor: HaloTokens.brandSoft, + disabledForegroundColor: HaloTokens.inkMuted, + elevation: 0, + padding: padding, + shape: shape, + textStyle: textStyle, + ), + child: child, + ), + ); + break; + case HaloButtonVariant.secondary: + button = OutlinedButton( + onPressed: onPressed, + style: OutlinedButton.styleFrom( + foregroundColor: HaloTokens.brandDark, + disabledForegroundColor: HaloTokens.inkMuted, + backgroundColor: HaloTokens.surface, + side: BorderSide( + color: disabled ? HaloTokens.border : HaloTokens.brandSoft, + ), + padding: padding, + shape: shape, + textStyle: textStyle, + ), + child: child, + ); + break; + case HaloButtonVariant.ghost: + button = TextButton( + onPressed: onPressed, + style: TextButton.styleFrom( + foregroundColor: HaloTokens.brandDark, + disabledForegroundColor: HaloTokens.inkMuted, + padding: padding, + shape: shape, + textStyle: textStyle, + ), + child: child, + ); + break; + } + + if (fullWidth) { + return SizedBox(width: double.infinity, child: button); + } + return button; + } + + Widget _content(TextStyle textStyle) { + if (icon == null) { + return Text(label, style: textStyle); + } + return Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconTheme( + data: IconThemeData(size: textStyle.fontSize! + 2), + child: icon!, + ), + const SizedBox(width: HaloSpacing.s8), + Text(label, style: textStyle), + ], + ); + } + + EdgeInsets _padding() { + switch (size) { + case HaloButtonSize.sm: + return const EdgeInsets.symmetric( + horizontal: HaloSpacing.s16, + vertical: HaloSpacing.s8, + ); + case HaloButtonSize.md: + return const EdgeInsets.symmetric( + horizontal: HaloSpacing.s24, + vertical: HaloSpacing.s12, + ); + case HaloButtonSize.lg: + return const EdgeInsets.symmetric( + horizontal: HaloSpacing.s32, + vertical: HaloSpacing.s16, + ); + } + } + + double _fontSize() { + switch (size) { + case HaloButtonSize.sm: + return 13; + case HaloButtonSize.md: + return 15; + case HaloButtonSize.lg: + return 16; + } + } +} diff --git a/client_app/lib/core/theme/widgets/halo_chip.dart b/client_app/lib/core/theme/widgets/halo_chip.dart new file mode 100644 index 0000000..d6238cd --- /dev/null +++ b/client_app/lib/core/theme/widgets/halo_chip.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import '../halo_tokens.dart'; + +class HaloChip extends StatelessWidget { + const HaloChip({ + super.key, + required this.label, + required this.selected, + required this.onTap, + this.icon, + }); + + final String label; + final bool selected; + final VoidCallback? onTap; + final Widget? icon; + + @override + Widget build(BuildContext context) { + final disabled = onTap == null; + final bgColor = selected + ? HaloTokens.brand + : disabled + ? HaloTokens.brandSofter + : HaloTokens.surface; + final fgColor = selected + ? Colors.white + : disabled + ? HaloTokens.inkMuted + : HaloTokens.ink; + final borderColor = selected ? HaloTokens.brand : HaloTokens.border; + + return Material( + color: bgColor, + borderRadius: HaloRadius.pill, + child: InkWell( + onTap: onTap, + borderRadius: HaloRadius.pill, + child: AnimatedContainer( + duration: HaloMotion.fast, + curve: HaloMotion.ease, + padding: const EdgeInsets.symmetric( + horizontal: HaloSpacing.s16, + vertical: HaloSpacing.s8, + ), + decoration: BoxDecoration( + border: Border.all(color: borderColor), + borderRadius: HaloRadius.pill, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + IconTheme( + data: IconThemeData(size: 16, color: fgColor), + child: icon!, + ), + const SizedBox(width: HaloSpacing.s8), + ], + Text( + label, + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 14, + fontWeight: FontWeight.w500, + color: fgColor, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/client_app/lib/core/theme/widgets/halo_orb.dart b/client_app/lib/core/theme/widgets/halo_orb.dart new file mode 100644 index 0000000..3ff68a9 --- /dev/null +++ b/client_app/lib/core/theme/widgets/halo_orb.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import '../halo_tokens.dart'; + +/// A soft gradient circle used as an avatar/identity glyph. +/// +/// `seed` deterministically picks a hue blend from the warm palette. +class HaloOrb extends StatelessWidget { + const HaloOrb({ + super.key, + required this.seed, + this.size = 64, + this.label, + }); + + final int seed; + final double size; + final String? label; + + static const List> _gradients = [ + [HaloTokens.brand, HaloTokens.brandDark], + [HaloTokens.accent, HaloTokens.brand], + [HaloTokens.lilac, HaloTokens.brand], + [HaloTokens.mint, HaloTokens.accent], + [HaloTokens.brandSoft, HaloTokens.brand], + [HaloTokens.accentSoft, HaloTokens.accent], + ]; + + @override + Widget build(BuildContext context) { + final colors = _gradients[seed.abs() % _gradients.length]; + final initial = (label ?? '').isNotEmpty + ? label!.substring(0, 1).toUpperCase() + : null; + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: colors, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: HaloShadows.soft, + ), + alignment: Alignment.center, + child: initial == null + ? null + : Text( + initial, + style: TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: size * 0.42, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ); + } +} diff --git a/client_app/lib/core/theme/widgets/halo_popup.dart b/client_app/lib/core/theme/widgets/halo_popup.dart new file mode 100644 index 0000000..7c721ef --- /dev/null +++ b/client_app/lib/core/theme/widgets/halo_popup.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import '../halo_tokens.dart'; +import 'halo_button.dart'; + +class HaloPopupAction { + const HaloPopupAction({required this.label, required this.onPressed}); + final String label; + final VoidCallback onPressed; +} + +class HaloPopup { + const HaloPopup._(); + + static Future show( + BuildContext context, { + required String title, + String? body, + Widget? icon, + HaloPopupAction? primary, + HaloPopupAction? secondary, + bool barrierDismissible = true, + }) { + return showDialog( + context: context, + barrierDismissible: barrierDismissible, + barrierColor: const Color(0x66000000), + builder: (ctx) => Dialog( + backgroundColor: HaloTokens.surface, + elevation: 0, + shape: const RoundedRectangleBorder(borderRadius: HaloRadius.xl), + insetPadding: const EdgeInsets.symmetric(horizontal: HaloSpacing.s24), + child: Padding( + padding: const EdgeInsets.all(HaloSpacing.s24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (icon != null) ...[ + Center(child: icon), + const SizedBox(height: HaloSpacing.s16), + ], + Text( + title, + style: const TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 22, + height: 28 / 22, + fontWeight: FontWeight.w700, + color: HaloTokens.ink, + ), + textAlign: TextAlign.center, + ), + if (body != null) ...[ + const SizedBox(height: HaloSpacing.s12), + Text( + body, + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 15, + height: 22 / 15, + color: HaloTokens.inkSoft, + ), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: HaloSpacing.s24), + if (primary != null) + HaloButton( + label: primary.label, + fullWidth: true, + onPressed: () { + Navigator.of(ctx).pop(); + primary.onPressed(); + }, + ), + if (secondary != null) ...[ + const SizedBox(height: HaloSpacing.s8), + HaloButton( + label: secondary.label, + variant: HaloButtonVariant.ghost, + fullWidth: true, + onPressed: () { + Navigator.of(ctx).pop(); + secondary.onPressed(); + }, + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/client_app/lib/core/theme/widgets/halo_snackbar.dart b/client_app/lib/core/theme/widgets/halo_snackbar.dart new file mode 100644 index 0000000..cc7197b --- /dev/null +++ b/client_app/lib/core/theme/widgets/halo_snackbar.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import '../halo_tokens.dart'; + +class HaloSnackbar { + const HaloSnackbar._(); + + static void show( + BuildContext context, + String message, { + String? icon, + Duration duration = const Duration(seconds: 4), + }) { + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger == null) return; + messenger + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + duration: duration, + behavior: SnackBarBehavior.floating, + backgroundColor: HaloTokens.ink, + elevation: 4, + shape: const RoundedRectangleBorder(borderRadius: HaloRadius.pill), + margin: const EdgeInsets.symmetric( + horizontal: HaloSpacing.s16, + vertical: HaloSpacing.s12, + ), + padding: const EdgeInsets.symmetric( + horizontal: HaloSpacing.s20, + vertical: HaloSpacing.s12, + ), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Text( + icon, + style: const TextStyle(fontSize: 18), + ), + const SizedBox(width: HaloSpacing.s8), + ], + Flexible( + child: Text( + message, + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/client_app/lib/core/theme/widgets/halo_step_dots.dart b/client_app/lib/core/theme/widgets/halo_step_dots.dart new file mode 100644 index 0000000..34572b0 --- /dev/null +++ b/client_app/lib/core/theme/widgets/halo_step_dots.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import '../halo_tokens.dart'; + +class HaloStepDots extends StatelessWidget { + const HaloStepDots({ + super.key, + required this.total, + required this.current, + }) : assert(total > 0), + assert(current >= 1); + + final int total; + final int current; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(total, (index) { + final step = index + 1; + final active = step == current; + final past = step < current; + return Padding( + padding: EdgeInsets.only(right: index == total - 1 ? 0 : HaloSpacing.s8), + child: AnimatedContainer( + duration: HaloMotion.fast, + curve: HaloMotion.ease, + width: active ? 24 : 8, + height: 8, + decoration: BoxDecoration( + color: active + ? HaloTokens.brand + : past + ? HaloTokens.brandSoft + : HaloTokens.border, + borderRadius: HaloRadius.pill, + ), + ), + ); + }), + ); + } +} diff --git a/client_app/lib/core/theme/widgets/widgets.dart b/client_app/lib/core/theme/widgets/widgets.dart new file mode 100644 index 0000000..62f5b85 --- /dev/null +++ b/client_app/lib/core/theme/widgets/widgets.dart @@ -0,0 +1,7 @@ +export 'halo_bottom_sheet.dart'; +export 'halo_button.dart'; +export 'halo_chip.dart'; +export 'halo_orb.dart'; +export 'halo_popup.dart'; +export 'halo_snackbar.dart'; +export 'halo_step_dots.dart'; diff --git a/client_app/lib/main.dart b/client_app/lib/main.dart index dbcec72..9fe0ce7 100644 --- a/client_app/lib/main.dart +++ b/client_app/lib/main.dart @@ -7,6 +7,7 @@ import 'core/auth/auth_notifier.dart'; import 'core/chat/active_session_notifier.dart'; import 'core/chat/chat_notifier.dart'; import 'core/notifications/notification_service.dart'; +import 'core/theme/halo_theme.dart'; import 'firebase_options.dart'; import 'router.dart'; @@ -83,6 +84,7 @@ class _AppState extends ConsumerState { return MaterialApp.router( title: 'Halo Bestie', + theme: haloThemeData(), routerConfig: router, ); } diff --git a/client_app/lib/router.dart b/client_app/lib/router.dart index 12bdb97..e188cf0 100644 --- a/client_app/lib/router.dart +++ b/client_app/lib/router.dart @@ -19,6 +19,7 @@ import 'features/chat/screens/chat_screen.dart'; import 'features/chat/screens/chat_history_screen.dart'; import 'features/chat/screens/chat_transcript_screen.dart'; import 'features/payment/screens/payment_screen.dart'; +import 'core/theme/_preview.dart'; class RouterNotifier extends ChangeNotifier { final Ref _ref; @@ -47,9 +48,12 @@ GoRouter buildRouter(Ref ref) { final notifier = RouterNotifier(ref); return GoRouter( - initialLocation: '/splash', + initialLocation: kThemePreviewEnabled ? '/_theme_preview' : '/splash', refreshListenable: notifier, redirect: (context, state) { + // Theme preview is dev-only and intentionally bypasses auth + onboarding + // gates so it can be opened on any device build. + if (state.matchedLocation == '/_theme_preview') return null; final authState = ref.read(authProvider); final isSplash = state.matchedLocation == '/splash'; final isOnboarding = state.matchedLocation == '/onboarding'; @@ -89,6 +93,8 @@ GoRouter buildRouter(Ref ref) { return null; }, routes: [ + if (kThemePreviewEnabled) + GoRoute(path: '/_theme_preview', builder: (_, __) => const ThemePreviewScreen()), GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()), GoRoute(path: '/onboarding', builder: (_, __) => const OnboardingScreen()), GoRoute(path: '/welcome', builder: (_, __) => const WelcomeScreen()), diff --git a/client_app/pubspec.yaml b/client_app/pubspec.yaml index df19716..e80e5a2 100644 --- a/client_app/pubspec.yaml +++ b/client_app/pubspec.yaml @@ -52,3 +52,21 @@ flutter: assets: - assets/images/ - assets/images/splash/ + - assets/fonts/ + + fonts: + - family: BricolageGrotesque + fonts: + - asset: assets/fonts/BricolageGrotesque-Variable.ttf + - family: Poppins + fonts: + - asset: assets/fonts/Poppins-Regular.ttf + - asset: assets/fonts/Poppins-Medium.ttf + weight: 500 + - asset: assets/fonts/Poppins-SemiBold.ttf + weight: 600 + - asset: assets/fonts/Poppins-Bold.ttf + weight: 700 + - family: JetBrainsMono + fonts: + - asset: assets/fonts/JetBrainsMono-Variable.ttf