Phase 4 Stage 0: design system foundation (client_app)

- HaloTokens, HaloSpacing, HaloRadius, HaloMotion, HaloShadows (warm palette;
  calm/playful stubbed for phase 5).
- Bundled Bricolage Grotesque, Poppins, JetBrains Mono (~1.2 MB total, OFL).
- haloThemeData() wired into MaterialApp.router with Figma-aligned text
  scale, pill ElevatedButton, 64px input height, 24px-corner BottomSheet,
  dark pill SnackBar.
- Halo* widget primitives: Button, Orb, StepDots, BottomSheet, Popup,
  Snackbar, Chip.
- Dev-only /_theme_preview route gated by --dart-define=THEME_PREVIEW=true
  for visual reference during stages 2-8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 15:56:00 +08:00
parent 8c212cb464
commit 4ada7c991a
21 changed files with 1308 additions and 1 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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`.

View File

@@ -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<ThemePreviewScreen> createState() => _ThemePreviewScreenState();
}
class _ThemePreviewScreenState extends State<ThemePreviewScreen> {
final Set<String> _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<void>(
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<void>(
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,
),
),
);
}
}

View File

@@ -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,
),
);
}

View File

@@ -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<BoxShadow> soft = [
BoxShadow(
color: Color(0x0A8C3255),
offset: Offset(0, 1),
blurRadius: 2,
),
BoxShadow(
color: Color(0x0F8C3255),
offset: Offset(0, 8),
blurRadius: 24,
),
];
static const List<BoxShadow> card = [
BoxShadow(
color: Color(0x0D8C3255),
offset: Offset(0, 2),
blurRadius: 6,
),
BoxShadow(
color: Color(0x1A8C3255),
offset: Offset(0, 18),
blurRadius: 40,
),
];
static const List<BoxShadow> button = [
BoxShadow(
color: Color(0x59E17A9D),
offset: Offset(0, 4),
blurRadius: 14,
),
];
}

View File

@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import '../halo_tokens.dart';
class HaloBottomSheet {
const HaloBottomSheet._();
static Future<T?> show<T>(
BuildContext context, {
required Widget child,
bool isDismissible = true,
bool enableDrag = true,
bool isScrollControlled = false,
}) {
return showModalBottomSheet<T>(
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,
),
),
);
}
}

View File

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

View File

@@ -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,
),
),
],
),
),
),
);
}
}

View File

@@ -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<List<Color>> _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,
),
),
);
}
}

View File

@@ -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<T?> show<T>(
BuildContext context, {
required String title,
String? body,
Widget? icon,
HaloPopupAction? primary,
HaloPopupAction? secondary,
bool barrierDismissible = true,
}) {
return showDialog<T>(
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();
},
),
],
],
),
),
),
);
}
}

View File

@@ -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,
),
),
),
],
),
),
);
}
}

View File

@@ -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,
),
),
);
}),
);
}
}

View File

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

View File

@@ -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<App> {
return MaterialApp.router(
title: 'Halo Bestie',
theme: haloThemeData(),
routerConfig: router,
);
}

View File

@@ -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()),

View File

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