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:
430
mitra_app/lib/features/profile/profil_screen.dart
Normal file
430
mitra_app/lib/features/profile/profil_screen.dart
Normal file
@@ -0,0 +1,430 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/auth/auth_notifier.dart';
|
||||
import '../../core/theme/halo_tokens.dart';
|
||||
import '../../core/theme/widgets/widgets.dart';
|
||||
|
||||
/// Bestie Profil tab — mirrors
|
||||
/// `figma-bestie/project/screens/v5.jsx::BestieProfile`.
|
||||
///
|
||||
/// Lives inside branch 2 of the shell (`router.dart`), so `BestieTabBar` is
|
||||
/// rendered by `ShellScreen` — this screen owns body content only.
|
||||
///
|
||||
/// Stage 4 deviation from the JSX: the Figma "Chat WhatsApp Kami / Chat
|
||||
/// Telegram Kami" rows surface customer-facing admin handles. Mitras are
|
||||
/// internal-only audience (see project memory `feedback_mitra_internal_audience`),
|
||||
/// so those two rows are replaced with a single "Hubungi Koordinator" entry
|
||||
/// pointing at the internal coordinator channel.
|
||||
class ProfilScreen extends ConsumerWidget {
|
||||
const ProfilScreen({super.key});
|
||||
|
||||
// TODO(stage-4): replace with `PackageInfo.fromPlatform().version` when
|
||||
// the `package_info_plus` package is added in a future change. Hardcoded
|
||||
// for now to avoid pulling a new dependency.
|
||||
static const String _appVersion = '1.0.0';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final authState = ref.watch(mitraAuthProvider);
|
||||
final authData = authState.valueOrNull;
|
||||
|
||||
final profile = authData is MitraAuthAuthenticatedData ? authData.profile : null;
|
||||
final displayName = (profile?['display_name'] as String?) ?? 'Bestie';
|
||||
final phone = (profile?['phone'] as String?) ?? '';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: HaloTokens.bg,
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 24, 20, 40),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const _Header(),
|
||||
const SizedBox(height: 20),
|
||||
_ProfileCard(displayName: displayName, phone: phone),
|
||||
const SizedBox(height: 28),
|
||||
_MenuList(
|
||||
onTapCoordinator: () => _snack(
|
||||
context,
|
||||
'Hubungi koordinator via grup internal — info lengkap segera tersedia',
|
||||
),
|
||||
onTapTerms: () => _snack(context, 'Segera tersedia'),
|
||||
onTapPrivacy: () => _snack(context, 'Segera tersedia'),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_DangerZone(
|
||||
onLogout: () => _confirmLogout(context, ref),
|
||||
onDelete: () => _snack(
|
||||
context,
|
||||
'Hubungi koordinator untuk penghapusan akun',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const _VersionFooter(version: _appVersion),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _snack(BuildContext context, String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
message,
|
||||
style: const TextStyle(fontFamily: HaloTokens.fontBody),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmLogout(BuildContext context, WidgetRef ref) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text(
|
||||
'Yakin mau keluar?',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.brandDark,
|
||||
),
|
||||
),
|
||||
content: const Text(
|
||||
'Kamu bakal sign-out dari akun mitra ini. Login lagi pakai nomor HP yang sama untuk masuk.',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 13.5,
|
||||
height: 1.4,
|
||||
color: HaloTokens.inkSoft,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text(
|
||||
'Batal',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
color: HaloTokens.inkSoft,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text(
|
||||
'Keluar',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
color: HaloTokens.danger,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
await ref.read(mitraAuthProvider.notifier).logout();
|
||||
// Router redirect handles navigation to /login on auth state change.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Header — centered "Profil" title (no back arrow; tab nav owns nav) ──
|
||||
class _Header extends StatelessWidget {
|
||||
const _Header();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'Profil',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.brandDark,
|
||||
letterSpacing: -0.4,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Profile card — HaloOrb + display name + role + phone ────────────────
|
||||
class _ProfileCard extends StatelessWidget {
|
||||
final String displayName;
|
||||
final String phone;
|
||||
|
||||
const _ProfileCard({required this.displayName, required this.phone});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Deterministic seed from phone — same number always gets the same orb.
|
||||
final seed = phone.isEmpty ? 0 : phone.hashCode;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: HaloTokens.surface,
|
||||
borderRadius: HaloRadius.lg,
|
||||
border: Border.all(color: HaloTokens.border),
|
||||
boxShadow: HaloShadows.soft,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
HaloOrb(size: 96, seed: seed),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
displayName,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.brandDark,
|
||||
letterSpacing: -0.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Bestie · Mitra Halo Bestie',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 13,
|
||||
color: HaloTokens.inkSoft,
|
||||
),
|
||||
),
|
||||
if (phone.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
phone,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontMono,
|
||||
fontSize: 12.5,
|
||||
color: HaloTokens.inkMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Menu list — 3 stacked _MenuTile items ───────────────────────────────
|
||||
class _MenuList extends StatelessWidget {
|
||||
final VoidCallback onTapCoordinator;
|
||||
final VoidCallback onTapTerms;
|
||||
final VoidCallback onTapPrivacy;
|
||||
|
||||
const _MenuList({
|
||||
required this.onTapCoordinator,
|
||||
required this.onTapTerms,
|
||||
required this.onTapPrivacy,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
_MenuTile(
|
||||
icon: Icons.support_agent_outlined,
|
||||
label: 'Hubungi Koordinator',
|
||||
subtitle: 'via grup koordinator internal',
|
||||
onTap: onTapCoordinator,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_MenuTile(
|
||||
icon: Icons.description_outlined,
|
||||
label: 'Syarat & Ketentuan',
|
||||
onTap: onTapTerms,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_MenuTile(
|
||||
icon: Icons.lock_outline,
|
||||
label: 'Kebijakan Privasi',
|
||||
onTap: onTapPrivacy,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MenuTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String? subtitle;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _MenuTile({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.subtitle,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: HaloTokens.surface,
|
||||
borderRadius: HaloRadius.md,
|
||||
child: InkWell(
|
||||
borderRadius: HaloRadius.md,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: 56),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: HaloRadius.md,
|
||||
border: Border.all(color: HaloTokens.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: const BoxDecoration(
|
||||
color: HaloTokens.brandSofter,
|
||||
borderRadius: HaloRadius.sm,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(icon, size: 18, color: HaloTokens.brandDark),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 11.5,
|
||||
color: HaloTokens.inkMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
size: 20,
|
||||
color: HaloTokens.inkMuted,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Danger zone — Keluar (secondary) + Hapus Akun (danger text) ─────────
|
||||
class _DangerZone extends StatelessWidget {
|
||||
final VoidCallback onLogout;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
const _DangerZone({required this.onLogout, required this.onDelete});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
HaloButton(
|
||||
label: 'Keluar',
|
||||
fullWidth: true,
|
||||
variant: HaloButtonVariant.secondary,
|
||||
onPressed: onLogout,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// Hapus Akun — destructive ghost-style with danger border + text.
|
||||
Material(
|
||||
color: HaloTokens.surface,
|
||||
borderRadius: HaloRadius.pill,
|
||||
child: InkWell(
|
||||
borderRadius: HaloRadius.pill,
|
||||
onTap: onDelete,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: HaloRadius.pill,
|
||||
border: Border.all(
|
||||
color: HaloTokens.danger.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.delete_outline,
|
||||
size: 17,
|
||||
color: HaloTokens.danger,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Hapus Akun',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: HaloTokens.danger,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Version footer ──────────────────────────────────────────────────────
|
||||
class _VersionFooter extends StatelessWidget {
|
||||
final String version;
|
||||
const _VersionFooter({required this.version});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'HaloBestie · v$version',
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 11,
|
||||
color: HaloTokens.inkMuted,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user