Mitra Profil: WA/TG contacts + Keluar-only (no Hapus Akun)

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>
This commit is contained in:
2026-05-21 16:32:21 +08:00
parent e4bffe1a71
commit 10699d1ad1
7 changed files with 174 additions and 99 deletions

View File

@@ -1,8 +1,10 @@
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`.
@@ -10,23 +12,23 @@ import '../../core/theme/widgets/widgets.dart';
/// 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.
/// 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. Hardcoded
// for now to avoid pulling a new dependency.
// 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';
@@ -45,20 +47,18 @@ class ProfilScreen extends ConsumerWidget {
_ProfileCard(displayName: displayName, phone: phone),
const SizedBox(height: 28),
_MenuList(
onTapCoordinator: () => _snack(
context,
'Hubungi koordinator via grup internal — info lengkap segera tersedia',
),
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),
_DangerZone(
onLogout: () => _confirmLogout(context, ref),
onDelete: () => _snack(
context,
'Hubungi koordinator untuk penghapusan akun',
),
HaloButton(
label: 'Keluar',
fullWidth: true,
variant: HaloButtonVariant.secondary,
onPressed: () => _confirmLogout(context, ref),
),
const SizedBox(height: 16),
const _VersionFooter(version: _appVersion),
@@ -69,6 +69,22 @@ class ProfilScreen extends ConsumerWidget {
);
}
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(
@@ -221,27 +237,41 @@ class _ProfileCard extends StatelessWidget {
}
}
// ─── Menu list — 3 stacked _MenuTile items ───────────────────────────────
// ─── Menu list — WA + Telegram + Terms + Privacy ─────────────────────────
class _MenuList extends StatelessWidget {
final VoidCallback onTapCoordinator;
final SupportHandles? handles;
final VoidCallback onTapWa;
final VoidCallback onTapTelegram;
final VoidCallback onTapTerms;
final VoidCallback onTapPrivacy;
const _MenuList({
required this.onTapCoordinator,
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.support_agent_outlined,
label: 'Hubungi Koordinator',
subtitle: 'via grup koordinator internal',
onTap: onTapCoordinator,
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(
@@ -342,73 +372,6 @@ class _MenuTile extends StatelessWidget {
}
}
// ─── 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;

View File

@@ -0,0 +1,43 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_client_provider.dart';
class SupportHandle {
final String label;
final String deeplink;
const SupportHandle({required this.label, required this.deeplink});
factory SupportHandle.fromJson(Map<String, dynamic> json) =>
SupportHandle(
label: json['label'] as String? ?? '',
deeplink: json['deeplink'] as String? ?? '',
);
/// Display form for the menu subtitle: strip the URL scheme so
/// "https://wa.me/6285173310010" → "wa.me/6285173310010" and
/// "https://t.me/halobestie" → "t.me/halobestie". Falls back to the
/// raw deeplink when there's no scheme to strip.
String get displayHandle {
if (deeplink.isEmpty) return '';
final stripped = deeplink.replaceFirst(RegExp(r'^https?://'), '');
return stripped;
}
}
class SupportHandles {
final SupportHandle? wa;
final SupportHandle? telegram;
const SupportHandles({this.wa, this.telegram});
factory SupportHandles.fromJson(Map<String, dynamic> json) {
SupportHandle? parse(dynamic v) =>
v is Map<String, dynamic> ? SupportHandle.fromJson(v) : null;
return SupportHandles(wa: parse(json['wa']), telegram: parse(json['telegram']));
}
}
final supportHandlesProvider = FutureProvider<SupportHandles>((ref) async {
final api = ref.read(apiClientProvider);
final response = await api.get('/api/shared/support-handles');
final data = response['data'] as Map<String, dynamic>? ?? const {};
return SupportHandles.fromJson(data);
});

View File

@@ -1004,6 +1004,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
url: "https://pub.dev"
source: hosted
version: "6.3.30"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
uuid:
dependency: transitive
description:

View File

@@ -31,6 +31,7 @@ dependencies:
# Navigation
go_router: ^13.2.1
flutter_local_notifications: ^21.0.0
url_launcher: ^6.3.0
dev_dependencies:
flutter_test: