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

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