Files
halobestie-clone/client_app/lib/features/profile/profile_screen.dart
Ramadhan Sjamsani e6d991373e Client Profile: save-phone banner for anonymous users
Anonymous customers now see a brand-gradient "Simpan Nomor HP" panel
above the user card on the kamu tab, ported from the Figma SProfile
save-phone banner. Tapping it pushes /auth/register?from=profile, which
hides the "lanjut tanpa verifikasi (harga normal)" link — a user who
re-entered the verif funnel from Profile shouldn't be re-offered the
anon exit. Spec §1.3 added documenting the ?from= entry-point
convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:39:13 +08:00

463 lines
14 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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 isAnonymous = authData is AuthAnonymousData;
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),
if (isAnonymous) ...[
_SavePhoneBanner(
onTap: () => context.push('/auth/register?from=profile'),
),
const SizedBox(height: 14),
],
_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 _SavePhoneBanner extends StatelessWidget {
final VoidCallback onTap;
const _SavePhoneBanner({required this.onTap});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [HaloTokens.brandSofter, HaloTokens.brandSoft],
),
borderRadius: HaloRadius.lg,
border: Border.all(color: HaloTokens.brand, width: 1.5),
boxShadow: [
BoxShadow(
color: HaloTokens.brand.withValues(alpha: 0.10),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 32,
height: 32,
decoration: const BoxDecoration(
color: HaloTokens.brand,
borderRadius: BorderRadius.all(Radius.circular(10)),
),
alignment: Alignment.center,
child: const Icon(
Icons.smartphone,
size: 18,
color: Colors.white,
),
),
const SizedBox(width: 10),
const Expanded(
child: Text(
'Biar riwayat curhat kamu tersimpan, yuk simpan Nomor Handphone kamu…',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13,
fontWeight: FontWeight.w600,
color: HaloTokens.brandDark,
height: 1.4,
),
),
),
],
),
const SizedBox(height: 12),
Material(
color: HaloTokens.brand,
borderRadius: HaloRadius.md,
child: InkWell(
onTap: onTap,
borderRadius: HaloRadius.md,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
alignment: Alignment.center,
child: const Text(
'Simpan Nomor HP',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13.5,
fontWeight: FontWeight.w600,
color: Colors.white,
letterSpacing: -0.07,
),
),
),
),
),
],
),
);
}
}
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,
),
),
],
),
),
),
);
}
}