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:
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';
|
||||
Reference in New Issue
Block a user