Phase 4 checkpoint: chat-screen perf refactor + retryable blast-failure + repo-wide dispose-ref guardrail
Chat-screen performance (customer + mitra): - Parent screens have zero `ref.watch` — only `ref.listen` for side effects - Body extracted into its own `ConsumerStatefulWidget`; AppBar parts split into narrow `.select` consumers (mode, sensitivity, timer) - Per-second timer ticks routed to dedicated providers (`chatRemainingSecondsProvider` + new `mitraChatRemainingSecondsProvider`) so WS `session_tick` frames don't invalidate the rest of the chat state Dispose-in-ref bug fix: - `home_screen.dart`, `payment_screen.dart`, `mitra_chat_screen.dart` — ref-using cleanup moved from `dispose()` to `deactivate()`. Modern Riverpod invalidates `ref` the moment `dispose()` runs; the resulting silent error corrupts the widget-tree finalize and the next screen appears frozen - `halo_lints` package added at repo root with `no_ref_in_dispose` rule to catch this pattern in CI / IDE analysis - `custom_lint` activated in both apps' `analysis_options.yaml` (was installed but never wired in — also brings `riverpod_lint`'s `avoid_ref_inside_state_dispose` online) - CLAUDE.md Pitfalls section added to client_app + mitra_app Phase 4 §3 retryable blast-failure (Option A): - Backend `expirePairingRequest` + all-rejected use `recordIntermediateFailure` instead of `failPaymentSession` so the payment session stays `confirmed` for re-blast - WS `pairing_failed` payload carries `is_terminal: false` on the retryable paths; client parses the flag and exposes `retryBlast()` - "Coba cari lagi" CTA on S7 Timeout now re-blasts on the same payment - Pairing service test updated to reflect the new semantics Customer waiting-payment screen navigation patch: - `_navigateTerminal` uses `Future.microtask` + `addPostFrameCallback` redundancy after a release-mode bug where polling stopped but `context.go` never fired, leaving the screen visually stuck on "menunggu pembayaran" See requirement/resume-2026-05-15.md for next-day pickup checklist (mitra release rebuild + S21 Ultra install + retest is the gating item). Bundles unrelated in-flight Phase 4 §2.x work that was already on disk (ESP screen removal, USP one-time gate scaffolding, bestie-availability public route, OTP service edits, Maestro flow tweaks) — kept together to avoid a partial-rebase mess. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
366
client_app/lib/features/profile/profile_screen.dart
Normal file
366
client_app/lib/features/profile/profile_screen.dart
Normal file
@@ -0,0 +1,366 @@
|
||||
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 '../home/widgets/halo_tab_bar.dart';
|
||||
|
||||
/// "Kamu" tab — profile screen.
|
||||
///
|
||||
/// Mirrors Figma `SProfile` (see `requirement/Figma/screens/extras.jsx::SProfile`):
|
||||
/// user card → menu list (kontak / syarat / privasi) → action button → version.
|
||||
///
|
||||
/// The action button differs from Figma: we ship **logout** here instead of
|
||||
/// the "hapus akun" CTA from the mockup. Account deletion is a deeper flow
|
||||
/// (confirmation, server-side data removal, refund policy) and is not in
|
||||
/// scope yet.
|
||||
class ProfileScreen extends ConsumerWidget {
|
||||
const ProfileScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final authData = ref.watch(authProvider).valueOrNull;
|
||||
|
||||
final (name, phone) = switch (authData) {
|
||||
AuthAuthenticatedData d => (
|
||||
(d.profile['display_name'] as String?) ?? 'kamu',
|
||||
_maskPhone(d.profile['phone'] as String?),
|
||||
),
|
||||
AuthAnonymousData d => (
|
||||
d.displayName.isEmpty ? 'kamu' : d.displayName,
|
||||
'akun anonim',
|
||||
),
|
||||
_ => ('kamu', null),
|
||||
};
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: HaloTokens.bg,
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 20, 24, 16),
|
||||
children: [
|
||||
const Text(
|
||||
'kamu',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.brandDark,
|
||||
letterSpacing: -0.52,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_UserCard(name: name, phone: phone),
|
||||
const SizedBox(height: 20),
|
||||
_MenuCard(items: [
|
||||
_MenuItemData(
|
||||
icon: Icons.mail_outline,
|
||||
label: 'kontak kami',
|
||||
sub: 'halo@halobestie.id',
|
||||
onTap: () {},
|
||||
),
|
||||
_MenuItemData(
|
||||
icon: Icons.description_outlined,
|
||||
label: 'syarat & ketentuan',
|
||||
onTap: () {},
|
||||
),
|
||||
_MenuItemData(
|
||||
icon: Icons.lock_outline,
|
||||
label: 'kebijakan privasi',
|
||||
onTap: () {},
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
_LogoutButton(
|
||||
onTap: () => _confirmLogout(context, ref),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Center(
|
||||
child: Text(
|
||||
'HaloBestie · v1.0.0',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 11,
|
||||
color: HaloTokens.inkMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const HaloTabBar(active: 'kamu'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmLogout(BuildContext context, WidgetRef ref) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: HaloTokens.surface,
|
||||
shape: const RoundedRectangleBorder(borderRadius: HaloRadius.lg),
|
||||
title: const Text(
|
||||
'keluar dari akun?',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.brandDark,
|
||||
),
|
||||
),
|
||||
content: const Text(
|
||||
'kamu harus login lagi buat lanjutin curhatan.',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
color: HaloTokens.inkSoft,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text(
|
||||
'batal',
|
||||
style: TextStyle(color: HaloTokens.inkMuted),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text(
|
||||
'keluar',
|
||||
style: TextStyle(
|
||||
color: HaloTokens.danger,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
// RouterNotifier observes the resulting AuthInitialData and sends the user
|
||||
// to /home (SHome1st), so no manual navigation is needed here.
|
||||
}
|
||||
|
||||
static String? _maskPhone(String? raw) {
|
||||
if (raw == null || raw.length < 6) return raw;
|
||||
final tail = raw.substring(raw.length - 4);
|
||||
final head = raw.substring(0, raw.length - 8);
|
||||
return '$head ••••$tail';
|
||||
}
|
||||
}
|
||||
|
||||
class _UserCard extends StatelessWidget {
|
||||
final String name;
|
||||
final String? phone;
|
||||
const _UserCard({required this.name, this.phone});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: HaloTokens.surface,
|
||||
borderRadius: HaloRadius.xl,
|
||||
border: Border.all(color: HaloTokens.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: RadialGradient(
|
||||
colors: [HaloTokens.brand, HaloTokens.lilac],
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.ink,
|
||||
letterSpacing: -0.18,
|
||||
),
|
||||
),
|
||||
if (phone != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
phone!,
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 12,
|
||||
color: HaloTokens.inkSoft,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MenuItemData {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String? sub;
|
||||
final VoidCallback onTap;
|
||||
const _MenuItemData({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.sub,
|
||||
required this.onTap,
|
||||
});
|
||||
}
|
||||
|
||||
class _MenuCard extends StatelessWidget {
|
||||
final List<_MenuItemData> items;
|
||||
const _MenuCard({required this.items});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: HaloTokens.surface,
|
||||
borderRadius: HaloRadius.lg,
|
||||
border: Border.all(color: HaloTokens.border),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < items.length; i++) ...[
|
||||
_MenuItemRow(item: items[i]),
|
||||
if (i < items.length - 1)
|
||||
const Divider(height: 1, thickness: 1, color: HaloTokens.border),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MenuItemRow extends StatelessWidget {
|
||||
final _MenuItemData item;
|
||||
const _MenuItemRow({required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: item.onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: const BoxDecoration(
|
||||
color: HaloTokens.brandSofter,
|
||||
borderRadius: BorderRadius.all(Radius.circular(10)),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(item.icon, size: 18, color: HaloTokens.brandDark),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
item.label,
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
),
|
||||
if (item.sub != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
item.sub!,
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 11.5,
|
||||
color: HaloTokens.inkMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
size: 18,
|
||||
color: HaloTokens.inkMuted,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LogoutButton extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
const _LogoutButton({required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: HaloTokens.surface,
|
||||
borderRadius: HaloRadius.lg,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: HaloRadius.lg,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: HaloRadius.lg,
|
||||
border: Border.all(
|
||||
color: HaloTokens.danger.withValues(alpha: 0.25),
|
||||
),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.logout, size: 18, color: HaloTokens.danger),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'keluar',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: HaloTokens.danger,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user