Mitra §A: pre-home (S3a/S3b/AccountInactive) + design system + Bestie Home

- Port halo_tokens + halo_theme + HaloButton to mitra_app (rose palette,
  Bricolage display, Poppins body, JetBrainsMono).
- Build S3a Input WhatsApp (figma-bestie BestieS3 first half) with
  +62 chip, leading-zero/62 normalization, allow '+' in input.
- Build S3b OTP verification (6-digit, 60s resend timer, attempts hint,
  Focus(canRequestFocus:false) for maestro inputText compat) with full
  error branching (CODE_MISMATCH, OTP_EXPIRED, OTP_USED, ATTEMPTS_EXCEEDED,
  WRONG_FLOW, ACCOUNT_INACTIVE).
- Add AccountInactive terminal screen for is_active=false mitras.
- Typed MitraAuthError with Indonesian-first localized messages +
  retryAfterSeconds passthrough.
- Rebuild home_screen.dart to match figma BestieHome (greeting + status
  card + Ganti Status CTA + Pengingat + 2-tile dark grid).
- Backend: POST /internal/_test/seed-mitra (idempotent) and
  PATCH /internal/mitras/:id (display_name update).
- Control center: inline Edit Nama on mitras row + expandable inline log
  table under clicked mitra (vs old below-table panel).
- 5 maestro flows ts-mitra-A-01/03/04/05/06 covering invalid input, happy
  path, account inactive, phone-format normalization, and the back-to-S3a
  regression. All green.

Plan + memory documented in:
- requirement/phase4-mitra-prehome-plan.md
- requirement/flow_mitra.md / flow_mitra.mermaid.md §A

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 22:01:28 +08:00
parent ad02ee252d
commit 9696eadeaf
37 changed files with 3406 additions and 326 deletions

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,132 @@
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);
// Launcher-icon background. Use this pink behind monochrome/white logos.
// For full-color logos, use `surface` (#FFFFFF) as the icon background.
static const Color brandLogoBg = Color(0xFFFF699F);
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,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 @@
export 'halo_button.dart';