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:
BIN
client_app/assets/fonts/BricolageGrotesque-Variable.ttf
Normal file
BIN
client_app/assets/fonts/BricolageGrotesque-Variable.ttf
Normal file
Binary file not shown.
BIN
client_app/assets/fonts/JetBrainsMono-Variable.ttf
Normal file
BIN
client_app/assets/fonts/JetBrainsMono-Variable.ttf
Normal file
Binary file not shown.
BIN
client_app/assets/fonts/Poppins-Bold.ttf
Normal file
BIN
client_app/assets/fonts/Poppins-Bold.ttf
Normal file
Binary file not shown.
BIN
client_app/assets/fonts/Poppins-Medium.ttf
Normal file
BIN
client_app/assets/fonts/Poppins-Medium.ttf
Normal file
Binary file not shown.
BIN
client_app/assets/fonts/Poppins-Regular.ttf
Normal file
BIN
client_app/assets/fonts/Poppins-Regular.ttf
Normal file
Binary file not shown.
BIN
client_app/assets/fonts/Poppins-SemiBold.ttf
Normal file
BIN
client_app/assets/fonts/Poppins-SemiBold.ttf
Normal file
Binary file not shown.
15
client_app/assets/fonts/README.md
Normal file
15
client_app/assets/fonts/README.md
Normal 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`.
|
||||||
289
client_app/lib/core/theme/_preview.dart
Normal file
289
client_app/lib/core/theme/_preview.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
316
client_app/lib/core/theme/halo_theme.dart
Normal file
316
client_app/lib/core/theme/halo_theme.dart
Normal 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
129
client_app/lib/core/theme/halo_tokens.dart
Normal file
129
client_app/lib/core/theme/halo_tokens.dart
Normal 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,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
42
client_app/lib/core/theme/widgets/halo_bottom_sheet.dart
Normal file
42
client_app/lib/core/theme/widgets/halo_bottom_sheet.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
152
client_app/lib/core/theme/widgets/halo_button.dart
Normal file
152
client_app/lib/core/theme/widgets/halo_button.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
client_app/lib/core/theme/widgets/halo_chip.dart
Normal file
75
client_app/lib/core/theme/widgets/halo_chip.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
client_app/lib/core/theme/widgets/halo_orb.dart
Normal file
61
client_app/lib/core/theme/widgets/halo_orb.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
client_app/lib/core/theme/widgets/halo_popup.dart
Normal file
94
client_app/lib/core/theme/widgets/halo_popup.dart
Normal 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();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
client_app/lib/core/theme/widgets/halo_snackbar.dart
Normal file
58
client_app/lib/core/theme/widgets/halo_snackbar.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
client_app/lib/core/theme/widgets/halo_step_dots.dart
Normal file
43
client_app/lib/core/theme/widgets/halo_step_dots.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
client_app/lib/core/theme/widgets/widgets.dart
Normal file
7
client_app/lib/core/theme/widgets/widgets.dart
Normal 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';
|
||||||
@@ -7,6 +7,7 @@ import 'core/auth/auth_notifier.dart';
|
|||||||
import 'core/chat/active_session_notifier.dart';
|
import 'core/chat/active_session_notifier.dart';
|
||||||
import 'core/chat/chat_notifier.dart';
|
import 'core/chat/chat_notifier.dart';
|
||||||
import 'core/notifications/notification_service.dart';
|
import 'core/notifications/notification_service.dart';
|
||||||
|
import 'core/theme/halo_theme.dart';
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
import 'router.dart';
|
import 'router.dart';
|
||||||
|
|
||||||
@@ -83,6 +84,7 @@ class _AppState extends ConsumerState<App> {
|
|||||||
|
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
title: 'Halo Bestie',
|
title: 'Halo Bestie',
|
||||||
|
theme: haloThemeData(),
|
||||||
routerConfig: router,
|
routerConfig: router,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import 'features/chat/screens/chat_screen.dart';
|
|||||||
import 'features/chat/screens/chat_history_screen.dart';
|
import 'features/chat/screens/chat_history_screen.dart';
|
||||||
import 'features/chat/screens/chat_transcript_screen.dart';
|
import 'features/chat/screens/chat_transcript_screen.dart';
|
||||||
import 'features/payment/screens/payment_screen.dart';
|
import 'features/payment/screens/payment_screen.dart';
|
||||||
|
import 'core/theme/_preview.dart';
|
||||||
|
|
||||||
class RouterNotifier extends ChangeNotifier {
|
class RouterNotifier extends ChangeNotifier {
|
||||||
final Ref _ref;
|
final Ref _ref;
|
||||||
@@ -47,9 +48,12 @@ GoRouter buildRouter(Ref ref) {
|
|||||||
final notifier = RouterNotifier(ref);
|
final notifier = RouterNotifier(ref);
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
initialLocation: '/splash',
|
initialLocation: kThemePreviewEnabled ? '/_theme_preview' : '/splash',
|
||||||
refreshListenable: notifier,
|
refreshListenable: notifier,
|
||||||
redirect: (context, state) {
|
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 authState = ref.read(authProvider);
|
||||||
final isSplash = state.matchedLocation == '/splash';
|
final isSplash = state.matchedLocation == '/splash';
|
||||||
final isOnboarding = state.matchedLocation == '/onboarding';
|
final isOnboarding = state.matchedLocation == '/onboarding';
|
||||||
@@ -89,6 +93,8 @@ GoRouter buildRouter(Ref ref) {
|
|||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
|
if (kThemePreviewEnabled)
|
||||||
|
GoRoute(path: '/_theme_preview', builder: (_, __) => const ThemePreviewScreen()),
|
||||||
GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()),
|
GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()),
|
||||||
GoRoute(path: '/onboarding', builder: (_, __) => const OnboardingScreen()),
|
GoRoute(path: '/onboarding', builder: (_, __) => const OnboardingScreen()),
|
||||||
GoRoute(path: '/welcome', builder: (_, __) => const WelcomeScreen()),
|
GoRoute(path: '/welcome', builder: (_, __) => const WelcomeScreen()),
|
||||||
|
|||||||
@@ -52,3 +52,21 @@ flutter:
|
|||||||
assets:
|
assets:
|
||||||
- assets/images/
|
- assets/images/
|
||||||
- assets/images/splash/
|
- 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
|
||||||
|
|||||||
Reference in New Issue
Block a user