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>
463 lines
14 KiB
Dart
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|