Replaces the placeholder "Hubungi Koordinator" row with two real
contacts pulled from backend config (support_handles_json), and drops
the "Hapus Akun" CTA. Mirrors the figma BestieProfile design but uses
the same WA/TG channel as the customer Tanya Admin sheet — business
decided the same ops team triages both audiences.
Backend:
- Promote support-handles route from /api/client to /api/shared
(renamed file + export). Both apps now consume the same endpoint;
hitting /api/client/* from mitra would violate the per-app
convention in mitra_app/CLAUDE.md.
- client_app provider updated to /api/shared/support-handles.
Mitra app:
- New support_handles_provider mirroring the client_app one. Adds a
`displayHandle` getter that strips the URL scheme for the subtitle
("https://wa.me/X" → "wa.me/X", "https://t.me/Y" → "t.me/Y") so the
row looks like the figma without exposing raw URLs.
- Profil screen now lists: Chat WhatsApp Kami, Chat Telegram Kami,
Syarat & Ketentuan, Kebijakan Privasi. Danger zone simplified to
Keluar only — mitras request account deletion through the same
WA/TG channels (no separate self-service path).
- url_launcher added as a runtime dep, launches deeplinks in
externalApplication mode with graceful snackbar fallback when
parsing or launching fails.
Updates [[feedback-mitra-internal-audience]] — pre-login rule still
holds (no admin CTAs on S3a/S3b/AccountInactive), but the post-login
Profil tab now does surface WA/TG. Overrides decided 2026-05-21.
Verified on emulator-5556: Profil tab renders both rows with handles
from `wa.me/6285173310010` + `t.me/halobestie`, Keluar present, no
Hapus Akun button.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
394 lines
12 KiB
Dart
394 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
import '../../core/auth/auth_notifier.dart';
|
|
import '../../core/theme/halo_tokens.dart';
|
|
import '../../core/theme/widgets/widgets.dart';
|
|
import 'support_handles_provider.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.
|
|
///
|
|
/// Menu items mirror the figma: Chat WhatsApp Kami + Chat Telegram Kami
|
|
/// (both deep-link via `support_handles_json` from backend config), then
|
|
/// Syarat & Ketentuan + Kebijakan Privasi (TODO content). Danger zone is
|
|
/// Keluar (logout) only — account deletion is intentionally not exposed
|
|
/// in-app; mitras request deletion via the same WA/TG channels.
|
|
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.
|
|
static const String _appVersion = '1.0.0';
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final authState = ref.watch(mitraAuthProvider);
|
|
final authData = authState.valueOrNull;
|
|
final handlesAsync = ref.watch(supportHandlesProvider);
|
|
|
|
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(
|
|
handles: handlesAsync.valueOrNull,
|
|
onTapWa: () => _launchHandle(context, handlesAsync.valueOrNull?.wa),
|
|
onTapTelegram: () => _launchHandle(context, handlesAsync.valueOrNull?.telegram),
|
|
onTapTerms: () => _snack(context, 'Segera tersedia'),
|
|
onTapPrivacy: () => _snack(context, 'Segera tersedia'),
|
|
),
|
|
const SizedBox(height: 24),
|
|
HaloButton(
|
|
label: 'Keluar',
|
|
fullWidth: true,
|
|
variant: HaloButtonVariant.secondary,
|
|
onPressed: () => _confirmLogout(context, ref),
|
|
),
|
|
const SizedBox(height: 16),
|
|
const _VersionFooter(version: _appVersion),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _launchHandle(BuildContext context, SupportHandle? handle) async {
|
|
if (handle == null || handle.deeplink.isEmpty) {
|
|
_snack(context, 'Kontak belum tersedia');
|
|
return;
|
|
}
|
|
final uri = Uri.tryParse(handle.deeplink);
|
|
if (uri == null) {
|
|
_snack(context, 'Tautan tidak valid');
|
|
return;
|
|
}
|
|
final ok = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
if (!ok && context.mounted) {
|
|
_snack(context, 'Gagal membuka ${handle.label}');
|
|
}
|
|
}
|
|
|
|
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 — WA + Telegram + Terms + Privacy ─────────────────────────
|
|
class _MenuList extends StatelessWidget {
|
|
final SupportHandles? handles;
|
|
final VoidCallback onTapWa;
|
|
final VoidCallback onTapTelegram;
|
|
final VoidCallback onTapTerms;
|
|
final VoidCallback onTapPrivacy;
|
|
|
|
const _MenuList({
|
|
required this.handles,
|
|
required this.onTapWa,
|
|
required this.onTapTelegram,
|
|
required this.onTapTerms,
|
|
required this.onTapPrivacy,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final waSub = handles?.wa?.displayHandle ?? '';
|
|
final tgSub = handles?.telegram?.displayHandle ?? '';
|
|
|
|
return Column(
|
|
children: [
|
|
_MenuTile(
|
|
icon: Icons.chat_bubble_outline,
|
|
label: 'Chat WhatsApp Kami',
|
|
subtitle: waSub.isEmpty ? null : waSub,
|
|
onTap: onTapWa,
|
|
),
|
|
const SizedBox(height: 10),
|
|
_MenuTile(
|
|
icon: Icons.send_outlined,
|
|
label: 'Chat Telegram Kami',
|
|
subtitle: tgSub.isEmpty ? null : tgSub,
|
|
onTap: onTapTelegram,
|
|
),
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── 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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|