Mitra Bestie §1–§3: shell + Undangan + popup + chat polish

Brings the mitra app to figma-bestie parity for Home (§1), Undangan
inbox with Curhat Baru + Perpanjang tabs (§2), and the incoming-popup
+ active-chat flow (§3). Home now lives inside a StatefulShellRoute
with BestieTabBar so Profil + Undangan + Home share one shell.

- Shell: features/shell/ (StatefulShellRoute, BestieTabBar, 3 branches)
- Undangan: features/undangan/ — Curhat Baru reads
  chatRequestProvider.pendingInvites; row Terima delegates accept to
  the notifier and ChatRequestOverlay owns nav (no double-push).
  Perpanjang tab stubbed (empty state) until backend exposes
  pendingExtensionsProvider.
- Profil: features/profile/ — Bestie-styled stub
- Home: refactored to body-only (shell owns chrome)
- Popup: chat_request_overlay + chat_request_notifier updated to
  serve the list rows, not just the modal
- Chat: mitra_chat_screen polish
- Theme: accentAmber tokens for the Perpanjang tab + halo_orb widget
  (loading spinner used by undangan list states)
- Login: replace broken GoRouterState location guard with
  _expectOtpPush flag — was stacking duplicate /otp pages on OTP
  resend (see project-otp-nav-bug-fixed-2026-05-21)

Maestro:
- 17 new flows under .maestro/flows/ts-mitra-{1,2,3}-* covering home
  online/offline variants, undangan empty/populated/tolak states,
  popup curhat-baru → accept → chat → ended banner, plus popup
  dismiss/expire/cancelled edge cases
- 4 new §A OTP flows (07/08/09/10) for invalid/mismatch/expired/cooldown
- Helper scripts: force_mitra_online/offline, force_pairing_timeout,
  force_session_expires_at, delete_mitra_status_row,
  customer_blast_now (js), customer_cancel_latest_blast
- Backend: POST /internal/_test/delete-mitra-status-row supports the
  "fresh mitra with no status row" test setup

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 11:14:30 +08:00
parent fcb8eaa505
commit fbc94daac7
59 changed files with 5039 additions and 687 deletions

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import '../halo_tokens.dart';
enum HaloButtonVariant { primary, secondary, ghost }
enum HaloButtonVariant { primary, secondary, ghost, soft, dark }
enum HaloButtonSize { sm, md, lg }
@@ -93,6 +93,52 @@ class HaloButton extends StatelessWidget {
child: child,
);
break;
case HaloButtonVariant.soft:
button = ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: HaloTokens.brandSofter,
foregroundColor: HaloTokens.brandDark,
disabledBackgroundColor: HaloTokens.brandSoft,
disabledForegroundColor: HaloTokens.inkMuted,
elevation: 0,
padding: padding,
shape: shape,
textStyle: textStyle,
),
child: child,
);
break;
case HaloButtonVariant.dark:
button = Container(
decoration: disabled
? null
: const BoxDecoration(
borderRadius: HaloRadius.pill,
boxShadow: [
BoxShadow(
color: Color(0x402A1820),
offset: Offset(0, 6),
blurRadius: 18,
),
],
),
child: ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: HaloTokens.ink,
foregroundColor: Colors.white,
disabledBackgroundColor: HaloTokens.inkMuted,
disabledForegroundColor: Colors.white70,
elevation: 0,
padding: padding,
shape: shape,
textStyle: textStyle,
),
child: child,
),
);
break;
}
if (fullWidth) {

View File

@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import '../halo_tokens.dart';
/// Decorative gradient blob avatar — the "Bestie" abstract avatar (not a face).
///
/// Ported from `mitra_app/figma-bestie/project/screens/primitives.jsx` (HBOrb).
/// The JSX renders a CSS `radial-gradient` plus two soft white highlight blobs
/// stacked over it. In Flutter we approximate with a `Stack`:
/// - base: `Container` w/ `RadialGradient` from top-left
/// - overlay 1: large soft white blob in upper-left (primary specular)
/// - overlay 2: smaller dimmer blob in lower-right (secondary highlight,
/// opposite the JSX which is also lower-right but very faint — keeping
/// the same position for visual parity)
/// - plus an outer drop shadow tinted by the seed's primary color.
///
/// Approximation note: CSS `filter: blur(6px)` on a sibling layer is emulated
/// with low-opacity white circles. It reads as "soft highlight" at a glance
/// without needing `ImageFilter.blur` (which would require ClipOval + BackdropFilter).
class HaloOrb extends StatelessWidget {
const HaloOrb({
super.key,
this.size = 120,
this.seed = 0,
});
/// Diameter in logical pixels.
final double size;
/// 04 selects a deterministic color pair from the warm palette seeds.
/// Out-of-range values are folded with modulo.
final int seed;
/// Warm-palette seed table — mirrors `HBOrb` colors in primitives.jsx:6873.
static const List<List<Color>> _seeds = [
[HaloTokens.brand, HaloTokens.accent],
[HaloTokens.brandDark, HaloTokens.lilac],
[HaloTokens.accent, HaloTokens.brand],
[HaloTokens.lilac, HaloTokens.brand],
[HaloTokens.mint, HaloTokens.brand],
];
@override
Widget build(BuildContext context) {
final pair = _seeds[seed.abs() % _seeds.length];
final primary = pair[0];
final secondary = pair[1];
return SizedBox(
width: size,
height: size,
child: Stack(
children: [
// Base radial gradient + drop shadow.
Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
center: const Alignment(-0.4, -0.4), // 30% / 30% in JSX
radius: 0.85,
colors: [primary, secondary],
stops: const [0.0, 0.7],
),
boxShadow: [
BoxShadow(
color: primary.withValues(alpha: 0.25),
offset: const Offset(0, 8),
blurRadius: 24,
),
],
),
),
// Inner shadow approximation — darker rim, bottom-right.
// Re-used the JSX `inset -8px -8px 20px rgba(0,0,0,0.12)` intent.
IgnorePointer(
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
center: const Alignment(0.6, 0.6),
radius: 0.85,
colors: [
Colors.black.withValues(alpha: 0.0),
Colors.black.withValues(alpha: 0.12),
],
stops: const [0.7, 1.0],
),
),
),
),
// Top-left specular highlight (larger, brighter).
Positioned(
top: size * 0.12,
left: size * 0.20,
child: Container(
width: size * 0.32,
height: size * 0.24,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.55),
),
),
),
// Bottom-right faint glint.
Positioned(
bottom: size * 0.14,
right: size * 0.18,
child: Container(
width: size * 0.18,
height: size * 0.14,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.25),
),
),
),
],
),
);
}
}

View File

@@ -1 +1,2 @@
export 'halo_button.dart';
export 'halo_orb.dart';