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:
@@ -27,6 +27,83 @@ class MitraAuthOtpSentData extends MitraAuthData {
|
||||
const MitraAuthOtpSentData(this.otpRequestId, {this.channelUsed});
|
||||
}
|
||||
|
||||
/// Structured error emitted by [MitraAuth] so screens can branch on
|
||||
/// the backend error code (popup vs snackbar vs full-screen state)
|
||||
/// instead of regex-matching message strings.
|
||||
///
|
||||
/// `toString()` returns [message] so any `Text(error.toString())` call
|
||||
/// site keeps working during the migration.
|
||||
class MitraAuthError implements Exception {
|
||||
final String code;
|
||||
final String message;
|
||||
final int? retryAfterSeconds;
|
||||
|
||||
const MitraAuthError(this.code, this.message, {this.retryAfterSeconds});
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
String _localizedRequestMessage(String? code, int? retryAfter) {
|
||||
switch (code) {
|
||||
case 'PHONE_INVALID':
|
||||
return 'Nomor HP tidak valid.';
|
||||
case 'OTP_COOLDOWN':
|
||||
return retryAfter != null
|
||||
? 'Tunggu ${retryAfter}s sebelum minta OTP lagi.'
|
||||
: 'Tunggu sebentar sebelum minta OTP lagi.';
|
||||
case 'OTP_RATE_LIMIT_PHONE':
|
||||
return 'Terlalu banyak permintaan OTP untuk nomor ini.';
|
||||
case 'OTP_RATE_LIMIT_IP':
|
||||
return 'Terlalu banyak permintaan OTP dari jaringan ini.';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
String _localizedVerifyMessage(String? code) {
|
||||
switch (code) {
|
||||
case 'CODE_INVALID':
|
||||
return 'Kode harus 6 digit angka.';
|
||||
case 'CODE_MISMATCH':
|
||||
return 'Kode OTP salah.';
|
||||
case 'OTP_EXPIRED':
|
||||
return 'Kode OTP kedaluwarsa. Minta kode baru.';
|
||||
case 'OTP_USED':
|
||||
return 'Kode OTP sudah digunakan.';
|
||||
case 'OTP_ATTEMPTS_EXCEEDED':
|
||||
return 'Terlalu banyak percobaan. Minta kode baru.';
|
||||
case 'WRONG_FLOW':
|
||||
return 'OTP tidak valid untuk login mitra.';
|
||||
case 'ACCOUNT_INACTIVE':
|
||||
return 'Akun tidak aktif. Hubungi koordinator.';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
MitraAuthError _buildError(
|
||||
DioException e,
|
||||
String Function(String? code, int? retryAfter) localize,
|
||||
String unknownMessage,
|
||||
) {
|
||||
final err = e.response?.data is Map ? e.response!.data['error'] : null;
|
||||
final code = err is Map ? err['code'] as String? : null;
|
||||
final serverMessage = err is Map ? err['message'] as String? : null;
|
||||
final details = err is Map ? err['details'] : null;
|
||||
final retryAfter = details is Map ? details['retry_after_seconds'] as int? : null;
|
||||
|
||||
// Prefer Indonesian localized copy for codes we know. Fall back to the
|
||||
// server message (which is English) only when the code is unrecognized,
|
||||
// and to a generic Indonesian unknown-message when neither is available.
|
||||
final localized = localize(code, retryAfter);
|
||||
final message = localized.isNotEmpty
|
||||
? localized
|
||||
: (serverMessage ?? unknownMessage);
|
||||
|
||||
return MitraAuthError(code ?? 'UNKNOWN', message, retryAfterSeconds: retryAfter);
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class MitraAuth extends _$MitraAuth {
|
||||
final _storage = TokenStorage();
|
||||
@@ -113,9 +190,15 @@ class MitraAuth extends _$MitraAuth {
|
||||
channelUsed: data['channel_used'] as String?,
|
||||
));
|
||||
} on DioException catch (e) {
|
||||
state = AsyncError(_otpRequestMessage(e), StackTrace.current);
|
||||
state = AsyncError(
|
||||
_buildError(e, _localizedRequestMessage, 'Gagal mengirim OTP. Coba lagi.'),
|
||||
StackTrace.current,
|
||||
);
|
||||
} catch (_) {
|
||||
state = AsyncError('Gagal mengirim OTP. Coba lagi.', StackTrace.current);
|
||||
state = AsyncError(
|
||||
const MitraAuthError('UNKNOWN', 'Gagal mengirim OTP. Coba lagi.'),
|
||||
StackTrace.current,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,9 +219,15 @@ class MitraAuth extends _$MitraAuth {
|
||||
_bridge.setAccessToken(accessToken);
|
||||
state = AsyncData(MitraAuthAuthenticatedData(profile));
|
||||
} on DioException catch (e) {
|
||||
state = AsyncError(_otpVerifyMessage(e), StackTrace.current);
|
||||
state = AsyncError(
|
||||
_buildError(e, (code, _) => _localizedVerifyMessage(code), 'Gagal verifikasi. Coba lagi.'),
|
||||
StackTrace.current,
|
||||
);
|
||||
} catch (_) {
|
||||
state = AsyncError('Gagal verifikasi. Coba lagi.', StackTrace.current);
|
||||
state = AsyncError(
|
||||
const MitraAuthError('UNKNOWN', 'Gagal verifikasi. Coba lagi.'),
|
||||
StackTrace.current,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,40 +241,4 @@ class MitraAuth extends _$MitraAuth {
|
||||
state = const AsyncData(MitraAuthInitialData());
|
||||
}
|
||||
|
||||
String _otpRequestMessage(DioException e) {
|
||||
final code = e.response?.data?['error']?['code'] as String?;
|
||||
switch (code) {
|
||||
case 'PHONE_INVALID':
|
||||
return 'Nomor HP tidak valid.';
|
||||
case 'OTP_COOLDOWN':
|
||||
return e.response?.data?['error']?['message'] as String? ??
|
||||
'Tunggu sebentar sebelum minta OTP lagi.';
|
||||
case 'OTP_RATE_LIMIT_PHONE':
|
||||
case 'OTP_RATE_LIMIT_IP':
|
||||
return 'Terlalu banyak permintaan OTP. Coba lagi nanti.';
|
||||
default:
|
||||
return 'Gagal mengirim OTP. Coba lagi.';
|
||||
}
|
||||
}
|
||||
|
||||
String _otpVerifyMessage(DioException e) {
|
||||
final code = e.response?.data?['error']?['code'] as String?;
|
||||
switch (code) {
|
||||
case 'ACCOUNT_INACTIVE':
|
||||
return 'Akun tidak aktif. Hubungi administrator.';
|
||||
case 'WRONG_FLOW':
|
||||
return 'OTP tidak valid untuk login mitra.';
|
||||
case 'CODE_MISMATCH':
|
||||
case 'CODE_INVALID':
|
||||
return 'Kode OTP salah.';
|
||||
case 'OTP_EXPIRED':
|
||||
return 'Kode OTP kedaluwarsa. Minta kode baru.';
|
||||
case 'OTP_USED':
|
||||
return 'Kode OTP sudah digunakan.';
|
||||
case 'OTP_ATTEMPTS_EXCEEDED':
|
||||
return 'Terlalu banyak percobaan. Minta kode baru.';
|
||||
default:
|
||||
return 'Gagal verifikasi. Coba lagi.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
316
mitra_app/lib/core/theme/halo_theme.dart
Normal file
316
mitra_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,
|
||||
),
|
||||
);
|
||||
}
|
||||
132
mitra_app/lib/core/theme/halo_tokens.dart
Normal file
132
mitra_app/lib/core/theme/halo_tokens.dart
Normal 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,
|
||||
),
|
||||
];
|
||||
}
|
||||
152
mitra_app/lib/core/theme/widgets/halo_button.dart
Normal file
152
mitra_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;
|
||||
}
|
||||
}
|
||||
}
|
||||
1
mitra_app/lib/core/theme/widgets/widgets.dart
Normal file
1
mitra_app/lib/core/theme/widgets/widgets.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'halo_button.dart';
|
||||
Reference in New Issue
Block a user