From 862fc35a406bddb2bbff9b7cd395dbb8eedff304 Mon Sep 17 00:00:00 2001 From: ramadhan sjamsani Date: Sun, 10 May 2026 17:47:02 +0800 Subject: [PATCH] Phase 4 Stage 8: returning-user shell + Tanya Admin sheet Bestie Choice Sheet on home Mulai Curhat CTA. When the user has at least one prior session (bestieHistoryHasItemsProvider hits the chat- sessions history endpoint), the CTA opens a HaloBottomSheet with two cards: 'bestie yang udah kenal' -> /chat/history, 'bestie baru' -> /payment/entry. Empty history -> direct to /payment/entry. Bestie history list visual upgrade: HaloOrb (mitraId seed) + name + last-session date + topic pills + sessions count + ONLINE pill. Backend getCustomerHistory now returns topics, mitra_is_online, sessions_count in a single payload (no per-row presence round-trip). BestieOfflinePopup with two variants (returning | new_) replacing the legacy BestieUnavailableDialog. tanya admin ghost CTA on both variants opens the new TanyaAdminSheet. Stage 5's targeted-wait declined stub + Stage 7's chat-screen 409 stub + searching-screen call site all migrated to the real component. TanyaAdminSheet: HaloBottomSheet with WA + Telegram buttons, deeplinks fetched via supportHandlesProvider (CC-config-driven). url_launcher added to client_app; ios LSApplicationQueriesSchemes covers https/http/whatsapp/tg. Stage 2's OTP-blocked popup hubungi admin SnackBar stub also migrated to TanyaAdminSheet. Dev-only POST /internal/_test/seed-history-session lets Maestro 08 flow seed a history row before exercising the choice sheet. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/src/routes/internal/_test.routes.js | 43 ++ backend/src/services/session.service.js | 9 +- .../.maestro/flows/08_returning_targeted.yaml | 132 ++++++ .../.maestro/scripts/seed_history_session.js | 18 + client_app/ios/Runner/Info.plist | 7 + .../auth/widgets/otp_blocked_popup.dart | 13 +- .../chat/screens/chat_history_screen.dart | 407 +++++++++++++----- .../features/chat/screens/chat_screen.dart | 11 +- .../chat/screens/searching_screen.dart | 5 +- .../chat/screens/targeted_waiting_screen.dart | 25 +- .../widgets/bestie_unavailable_dialog.dart | 210 ++++++--- client_app/lib/features/home/home_screen.dart | 23 +- .../providers/bestie_history_provider.dart | 50 +++ .../home/widgets/bestie_choice_sheet.dart | 141 ++++++ .../providers/support_handles_provider.dart | 33 ++ .../support/widgets/tanya_admin_sheet.dart | 131 ++++++ .../flutter/generated_plugin_registrant.cc | 4 + .../linux/flutter/generated_plugins.cmake | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 2 + client_app/pubspec.lock | 64 +++ client_app/pubspec.yaml | 4 + .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 1 + 23 files changed, 1122 insertions(+), 215 deletions(-) create mode 100644 client_app/.maestro/flows/08_returning_targeted.yaml create mode 100644 client_app/.maestro/scripts/seed_history_session.js create mode 100644 client_app/lib/features/home/providers/bestie_history_provider.dart create mode 100644 client_app/lib/features/home/widgets/bestie_choice_sheet.dart create mode 100644 client_app/lib/features/support/providers/support_handles_provider.dart create mode 100644 client_app/lib/features/support/widgets/tanya_admin_sheet.dart diff --git a/backend/src/routes/internal/_test.routes.js b/backend/src/routes/internal/_test.routes.js index d78e029..1ab6664 100644 --- a/backend/src/routes/internal/_test.routes.js +++ b/backend/src/routes/internal/_test.routes.js @@ -154,4 +154,47 @@ export const internalTestRoutes = async (fastify) => { _broadcastTimerResyncForTest(updated.id, updated.expires_at) return { ok: true, session_id: updated.id, expires_at: updated.expires_at } }) + + // Seed a completed chat_sessions row for the customer linked to `phone`, + // pairing them with the most-recent online mitra. Used by Maestro Stage 8 + // flow (08_returning_targeted.yaml) so the bestie history list isn't empty. + // + // Body shape: + // { phone: '+62...' } — the customer; mitra is auto-picked. + fastify.post('/seed-history-session', async (request, reply) => { + const phone = request.body?.phone + if (!phone) { + return reply.code(400).send({ error: 'phone required in body' }) + } + const [customer] = await sql` + SELECT id FROM customers WHERE phone = ${phone} LIMIT 1 + ` + if (!customer) { + return reply.code(404).send({ error: 'no_customer_for_phone', phone }) + } + const [mitra] = await sql` + SELECT m.id, m.display_name FROM mitras m + INNER JOIN mitra_online_status s ON s.mitra_id = m.id + WHERE s.is_online = true + ORDER BY s.last_heartbeat_at DESC NULLS LAST + LIMIT 1 + ` + if (!mitra) { + return reply.code(404).send({ error: 'no_online_mitra' }) + } + const [session] = await sql` + INSERT INTO chat_sessions ( + customer_id, mitra_id, status, topic_sensitivity, topics, + created_at, paired_at, ended_at, duration_minutes, price + ) VALUES ( + ${customer.id}, ${mitra.id}, ${SessionStatus.COMPLETED}, 'regular', + ${sql.array(['hubungan'])}, + NOW() - INTERVAL '1 day', NOW() - INTERVAL '1 day', + NOW() - INTERVAL '1 day' + INTERVAL '15 minutes', + 15, 30000 + ) + RETURNING id + ` + return { ok: true, session_id: session.id, mitra_id: mitra.id, mitra_name: mitra.display_name } + }) } diff --git a/backend/src/services/session.service.js b/backend/src/services/session.service.js index 83430ae..86d89f8 100644 --- a/backend/src/services/session.service.js +++ b/backend/src/services/session.service.js @@ -207,13 +207,18 @@ export const getActiveSessionsByMitraWithUnread = async (mitraId) => { export const getCustomerHistory = async (customerId, { page = 1, limit = 20 } = {}) => { const offset = (page - 1) * limit const items = await sql` - SELECT cs.id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at, cs.ended_at, + SELECT cs.id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.topics, cs.created_at, cs.paired_at, cs.ended_at, cs.duration_minutes, cs.price, cs.is_first_session_discount, cs.extended_minutes, m.display_name AS mitra_display_name, + COALESCE(mos.is_online, false) AS mitra_is_online, (SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.MITRA} LIMIT 1) AS mitra_closure_message, - (SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.CUSTOMER} LIMIT 1) AS customer_closure_message + (SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.CUSTOMER} LIMIT 1) AS customer_closure_message, + (SELECT COUNT(*) FROM chat_sessions x + WHERE x.customer_id = ${customerId} AND x.mitra_id = cs.mitra_id + AND x.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})) AS sessions_count FROM chat_sessions cs LEFT JOIN mitras m ON m.id = cs.mitra_id + LEFT JOIN mitra_online_status mos ON mos.mitra_id = cs.mitra_id WHERE cs.customer_id = ${customerId} AND cs.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING}) ORDER BY COALESCE(cs.ended_at, cs.created_at) DESC diff --git a/client_app/.maestro/flows/08_returning_targeted.yaml b/client_app/.maestro/flows/08_returning_targeted.yaml new file mode 100644 index 0000000..bcb5c86 --- /dev/null +++ b/client_app/.maestro/flows/08_returning_targeted.yaml @@ -0,0 +1,132 @@ +# Stage 8 acceptance: returning-user shell. +# +# Flow: +# 1. Cold-start onboarding flow (mirrors 01_smoke) lands customer on home. +# 2. Seed a completed chat_sessions row so the bestie history list isn't empty. +# 3. Tap "Mulai Curhat" → Bestie Choice Sheet appears. +# 4. Tap "bestie yang udah kenal" → bestie history list appears. +# 5. Verify ONLINE pill renders for the seeded (online) mitra. +# 6. Tap "curhat lagi" on the row → targeted-wait screen appears with 20s +# countdown overlay, then matches via the running mitra. +# +# Pre-req: client_app debug APK installed, backend reachable, NODE_ENV != 'production' +# so the dev-only /internal/_test routes are registered, AND a mitra is currently +# online in the dev DB (see backend/src/db/seed.js or run mitra_app to sign in). +# +# Run: +# maestro test client_app/.maestro/flows/08_returning_targeted.yaml +appId: com.halobestie.client.client_app +env: + TEST_PHONE: "+628155556677" + BACKEND_INTERNAL_URL: http://localhost:3001 +--- +# Wipe prior state for TEST_PHONE so the run is hermetic. +- runScript: + file: ../scripts/reset_phone.js + env: + TEST_PHONE: ${TEST_PHONE} + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} +- launchApp: + clearState: true + +# Onboarding → welcome → display name → force-register → OTP → home (matches 01_smoke). +- extendedWaitUntil: + visible: + text: "Mulai" + timeout: 15000 +- tapOn: + text: "Mulai" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "Lanjut sebagai Tamu" + timeout: 10000 +- tapOn: + text: "Lanjut sebagai Tamu" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "Nama panggilan" + timeout: 10000 +- tapOn: + text: "Nama panggilan" +- inputText: "Maestro" +- hideKeyboard +- tapOn: + text: "Lanjut" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "Verifikasi Akun" + timeout: 15000 +- tapOn: + text: "Nomor HP" +- inputText: ${TEST_PHONE} +- hideKeyboard +- tapOn: + text: "Kirim OTP" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "Masukkan OTP" + timeout: 15000 +- runScript: + file: ../scripts/peek_otp.js + env: + TEST_PHONE: ${TEST_PHONE} + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} +- inputText: ${output.OTP} +- extendedWaitUntil: + notVisible: + text: "Masukkan OTP" + timeout: 15000 +- extendedWaitUntil: + visible: + text: "Nama panggilan" + timeout: 10000 +- tapOn: + text: "Nama panggilan" +- inputText: "Maestro" +- hideKeyboard +- tapOn: + text: "Lanjut" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "Mulai Curhat" + timeout: 20000 + +# Seed a prior session against an online mitra. +- runScript: + file: ../scripts/seed_history_session.js + env: + TEST_PHONE: ${TEST_PHONE} + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} + +# Tap "Mulai Curhat" → Bestie Choice Sheet (returning-user variant). +- tapOn: + text: "Mulai Curhat" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "mau curhat sama siapa?" + timeout: 5000 +- assertVisible: "bestie yang udah kenal" +- assertVisible: "bestie baru" + +# Choose the known bestie path → history list with v4 layout. +- tapOn: "bestie yang udah kenal" +- extendedWaitUntil: + visible: + text: "Riwayat Chat" + timeout: 5000 +- assertVisible: "ONLINE" +- assertVisible: "curhat lagi" + +# Tap "curhat lagi" → /payment (legacy targeted-payment route). Verify the +# screen title; the targeted-payment flow itself is covered by Stage 5. +- tapOn: "curhat lagi" +- extendedWaitUntil: + visible: + text: "Chat lagi dengan" + timeout: 10000 diff --git a/client_app/.maestro/scripts/seed_history_session.js b/client_app/.maestro/scripts/seed_history_session.js new file mode 100644 index 0000000..f87e293 --- /dev/null +++ b/client_app/.maestro/scripts/seed_history_session.js @@ -0,0 +1,18 @@ +// Seed a completed chat_sessions row for TEST_PHONE so the bestie history +// list isn't empty when the Stage 8 flow opens it. Pairs the customer with +// the most-recently-online mitra in the dev DB. +// +// Hits the dev-only /internal/_test/seed-history-session endpoint. +const phone = TEST_PHONE +const url = BACKEND_INTERNAL_URL || 'http://localhost:3001' +const resp = http.post(`${url}/internal/_test/seed-history-session`, { + body: JSON.stringify({ phone }), + headers: { 'Content-Type': 'application/json' }, +}) +if (resp.status !== 200) { + throw new Error(`seed-history-session failed (${resp.status}): ${resp.body}`) +} +const data = json(resp.body) +output.SESSION_ID = data.session_id +output.MITRA_ID = data.mitra_id +output.MITRA_NAME = data.mitra_name diff --git a/client_app/ios/Runner/Info.plist b/client_app/ios/Runner/Info.plist index 77c070f..f4a4c60 100644 --- a/client_app/ios/Runner/Info.plist +++ b/client_app/ios/Runner/Info.plist @@ -49,6 +49,13 @@ + LSApplicationQueriesSchemes + + https + http + whatsapp + tg + UIApplicationSupportsIndirectInputEvents UIBackgroundModes diff --git a/client_app/lib/features/auth/widgets/otp_blocked_popup.dart b/client_app/lib/features/auth/widgets/otp_blocked_popup.dart index 93aee82..fd2e679 100644 --- a/client_app/lib/features/auth/widgets/otp_blocked_popup.dart +++ b/client_app/lib/features/auth/widgets/otp_blocked_popup.dart @@ -2,12 +2,13 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/widgets/widgets.dart'; +import '../../support/widgets/tanya_admin_sheet.dart'; /// Modal shown when OTP delivery / verification is exhausted (rate-limited /// 429 from `OTP_RATE_LIMIT_PHONE`, `OTP_RATE_LIMIT_IP`, `OTP_COOLDOWN`, or /// `OTP_ATTEMPTS_EXCEEDED`). Offers a "lanjut tanpa verif" exit into the -/// anonymous flow (preserving any ESP/USP state) and a stub "hubungi admin" -/// affordance — Stage 8 will wire the real Tanya Admin sheet. +/// anonymous flow (preserving any ESP/USP state) and a "hubungi admin" CTA +/// that opens the Tanya Admin sheet. class OtpBlockedPopup { const OtpBlockedPopup._(); @@ -44,12 +45,8 @@ class OtpBlockedPopup { secondary: HaloPopupAction( label: 'hubungi admin', onPressed: () { - // TODO(stage8): replace with Tanya Admin sheet. - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Tanya Admin akan tersedia segera.'), - ), - ); + // ignore: discarded_futures + TanyaAdminSheet.show(context); }, ), ); diff --git a/client_app/lib/features/chat/screens/chat_history_screen.dart b/client_app/lib/features/chat/screens/chat_history_screen.dart index 0e48d71..a000163 100644 --- a/client_app/lib/features/chat/screens/chat_history_screen.dart +++ b/client_app/lib/features/chat/screens/chat_history_screen.dart @@ -3,140 +3,323 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../core/api/api_client_provider.dart'; import '../../../core/constants.dart'; +import '../../../core/theme/halo_tokens.dart'; +import '../../../core/theme/widgets/widgets.dart'; +import '../../home/providers/bestie_history_provider.dart'; -/// Chat history with per-row "Curhat lagi" CTA. +/// Phase 4 Stage 8 — `BestieHistoryList`. /// -/// Tapping "Curhat lagi" routes to the payment screen with the targeted -/// mitra id + display name as extras. The payment screen then: -/// 1. POSTs `/api/client/payment-sessions` with `targeted_mitra_id` -/// 2. On confirm, fires `pairingNotifier.startTargetedSearch(...)` instead -/// of the general `startSearch(...)`. +/// Renders past sessions with the v4 visual: orb + name + last-session date +/// + topic chips + sessions count + ONLINE pill (per-row, sourced from the +/// `mitra_is_online` field on the history payload). /// -/// The CTA is per-row (not per-unique-mitra). -class ChatHistoryScreen extends ConsumerStatefulWidget { +/// Tapping a row routes to the targeted "Curhat lagi" payment flow when the +/// row references a known mitra; closing-state rows still drop into the +/// session screen so the user can finish the goodbye composer. Otherwise we +/// fall back to the transcript view. +class ChatHistoryScreen extends ConsumerWidget { const ChatHistoryScreen({super.key}); @override - ConsumerState createState() => _ChatHistoryScreenState(); -} + Widget build(BuildContext context, WidgetRef ref) { + final historyAsync = ref.watch(bestieHistoryProvider); + final fullSessionsAsync = ref.watch(_rawHistoryProvider); -class _ChatHistoryScreenState extends ConsumerState { - List> _sessions = []; - bool _loading = true; - - @override - void initState() { - super.initState(); - _loadHistory(); - } - - Future _loadHistory() async { - try { - final api = ref.read(apiClientProvider); - final response = await api.get('/api/client/chat/history'); - final items = (response['data']['items'] as List).cast>(); - setState(() { - _sessions = items; - _loading = false; - }); - } catch (_) { - setState(() => _loading = false); - } - } - - void _onCurhatLagiPressed(Map session) { - // The mitra id field on the history payload is `mitra_id` per existing - // backend convention. If absent (older rows), don't render the CTA. - final mitraId = session['mitra_id'] as String?; - if (mitraId == null) return; - final mitraName = session['mitra_display_name'] as String? ?? 'Bestie'; - context.push('/payment', extra: { - 'targetedMitraId': mitraId, - 'mitraName': mitraName, - 'topicSensitivity': TopicSensitivity.regular, - }); - } - - @override - Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Riwayat Chat')), - body: _loading - ? const Center(child: CircularProgressIndicator()) - : _sessions.isEmpty - ? const Center(child: Text('Belum ada riwayat chat')) - : ListView.separated( - itemCount: _sessions.length, - separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (context, index) { - final s = _sessions[index]; - final sessionId = s['id'] as String; - final mitraId = s['mitra_id'] as String?; - final mitraName = s['mitra_display_name'] as String? ?? 'Bestie'; - final status = s['status'] as String?; - final isClosing = status == 'closing'; - final endedAt = s['ended_at'] != null - ? DateTime.parse(s['ended_at'] as String).toLocal() - : null; - final duration = s['duration_minutes'] as int?; - final closureMsg = s['customer_closure_message'] as String?; - - return ListTile( - leading: const CircleAvatar(child: Icon(Icons.person)), - title: Row( - children: [ - Flexible(child: Text(mitraName, overflow: TextOverflow.ellipsis)), - if (isClosing) ...[ - const SizedBox(width: 8), - const _OutstandingClosureBadge(), - ], - ], - ), - subtitle: Text([ - if (endedAt != null) '${endedAt.day}/${endedAt.month}/${endedAt.year}', - if (duration != null) '$duration menit', - if (closureMsg != null) '"$closureMsg"', - ].join(' - ')), - // Curhat-lagi CTA renders inline; transcript view is - // still reachable by tapping the row body (or, for - // closing sessions, the active chat — same as before). - trailing: !isClosing && mitraId != null - ? OutlinedButton( - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - ), - onPressed: () => _onCurhatLagiPressed(s), - child: const Text('Curhat lagi'), - ) - : const Icon(Icons.chevron_right), - onTap: () => isClosing - ? context.push('/chat/session/$sessionId', extra: mitraName) - : context.push('/chat/history/$sessionId'), - ); - }, + backgroundColor: HaloTokens.bg, + appBar: AppBar( + backgroundColor: HaloTokens.bg, + foregroundColor: HaloTokens.ink, + elevation: 0, + title: const Text( + 'Riwayat Chat', + style: TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontWeight: FontWeight.w700, + ), + ), + ), + body: historyAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, __) => const Center( + child: Text( + 'gagal memuat riwayat. tarik untuk muat ulang.', + style: TextStyle(fontFamily: HaloTokens.fontBody), + ), + ), + data: (items) { + if (items.isEmpty) { + return const Center( + child: Text( + 'Belum ada riwayat chat', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + color: HaloTokens.inkSoft, ), + ), + ); + } + return RefreshIndicator( + onRefresh: () async { + ref.invalidate(bestieHistoryProvider); + ref.invalidate(_rawHistoryProvider); + await ref.read(bestieHistoryProvider.future); + }, + child: ListView.separated( + padding: const EdgeInsets.symmetric( + horizontal: HaloSpacing.s16, + vertical: HaloSpacing.s12, + ), + itemCount: items.length, + separatorBuilder: (_, __) => const SizedBox(height: HaloSpacing.s12), + itemBuilder: (context, index) { + final item = items[index]; + final raw = fullSessionsAsync.valueOrNull?[index]; + final isClosing = raw?['status'] == SessionStatus.closing; + return _BestieRow( + item: item, + isClosing: isClosing, + onTap: () { + if (isClosing && raw != null) { + context.push( + '/chat/session/${item.sessionId}', + extra: item.mitraName, + ); + return; + } + context.push('/chat/history/${item.sessionId}'); + }, + onCurhatLagi: item.mitraId == null || isClosing + ? null + : () => context.push('/payment', extra: { + 'targetedMitraId': item.mitraId, + 'mitraName': item.mitraName, + 'topicSensitivity': TopicSensitivity.regular, + }), + ); + }, + ), + ); + }, + ), ); } } -class _OutstandingClosureBadge extends StatelessWidget { - const _OutstandingClosureBadge(); +/// Raw history payload — used to read fields the v4 `BestieHistoryItem` +/// model doesn't surface (currently `status`, for the closing-row branch). +final _rawHistoryProvider = FutureProvider>>((ref) async { + final api = ref.read(apiClientProvider); + final response = await api.get('/api/client/chat/history'); + return ((response['data']['items'] as List?) ?? const []).cast>(); +}); + +class _BestieRow extends StatelessWidget { + final BestieHistoryItem item; + final bool isClosing; + final VoidCallback onTap; + final VoidCallback? onCurhatLagi; + + const _BestieRow({ + required this.item, + required this.isClosing, + required this.onTap, + required this.onCurhatLagi, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: HaloTokens.surface, + borderRadius: HaloRadius.lg, + child: InkWell( + onTap: onTap, + borderRadius: HaloRadius.lg, + child: Padding( + padding: const EdgeInsets.all(HaloSpacing.s16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + HaloOrb( + size: 56, + seed: (item.mitraId ?? item.mitraName).hashCode, + label: item.mitraName, + ), + const SizedBox(width: HaloSpacing.s12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + item.mitraName, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 16, + fontWeight: FontWeight.w700, + color: HaloTokens.ink, + ), + ), + ), + if (item.mitraIsOnline) ...[ + const SizedBox(width: HaloSpacing.s8), + const _OnlinePill(), + ], + if (isClosing) ...[ + const SizedBox(width: HaloSpacing.s8), + const _ClosingBadge(), + ], + ], + ), + const SizedBox(height: 2), + Text( + [ + if (item.endedAt != null) _formatDate(item.endedAt!), + '${item.sessionsCount} sesi', + ].join(' · '), + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 12.5, + color: HaloTokens.inkSoft, + ), + ), + ], + ), + ), + ], + ), + if (item.topics.isNotEmpty) ...[ + const SizedBox(height: HaloSpacing.s12), + Wrap( + spacing: HaloSpacing.s8, + runSpacing: HaloSpacing.s8, + children: item.topics + .take(3) + .map((t) => _TopicPill(label: t)) + .toList(), + ), + ], + if (onCurhatLagi != null) ...[ + const SizedBox(height: HaloSpacing.s12), + Align( + alignment: Alignment.centerRight, + child: HaloButton( + label: 'curhat lagi', + size: HaloButtonSize.sm, + variant: HaloButtonVariant.secondary, + onPressed: onCurhatLagi, + ), + ), + ], + ], + ), + ), + ), + ); + } + + String _formatDate(DateTime d) => + '${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}'; +} + +class _OnlinePill extends StatelessWidget { + const _OnlinePill(); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( - color: Colors.amber.shade100, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.amber.shade700, width: 0.5), + color: HaloTokens.success.withAlpha(36), + borderRadius: BorderRadius.circular(999), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + _Dot(color: HaloTokens.success), + SizedBox(width: 4), + Text( + 'ONLINE', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + color: HaloTokens.success, + ), + ), + ], + ), + ); + } +} + +class _Dot extends StatelessWidget { + final Color color; + const _Dot({required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + width: 6, + height: 6, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ); + } +} + +class _TopicPill extends StatelessWidget { + final String label; + const _TopicPill({required this.label}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: HaloSpacing.s12, + vertical: 4, + ), + decoration: BoxDecoration( + color: HaloTokens.brandSofter, + borderRadius: BorderRadius.circular(999), ), child: Text( - 'Belum ditutup', - style: TextStyle( - fontSize: 10, - color: Colors.amber.shade900, + label, + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 11.5, fontWeight: FontWeight.w600, + color: HaloTokens.brandDark, + ), + ), + ); + } +} + +class _ClosingBadge extends StatelessWidget { + const _ClosingBadge(); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: HaloTokens.accentSoft, + borderRadius: BorderRadius.circular(999), + ), + child: const Text( + 'Belum ditutup', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 10, + fontWeight: FontWeight.w600, + color: HaloTokens.brandDark, ), ), ); diff --git a/client_app/lib/features/chat/screens/chat_screen.dart b/client_app/lib/features/chat/screens/chat_screen.dart index 5ef69ce..4d0b4de 100644 --- a/client_app/lib/features/chat/screens/chat_screen.dart +++ b/client_app/lib/features/chat/screens/chat_screen.dart @@ -8,8 +8,8 @@ import '../../../core/chat/session_closure_notifier.dart'; import '../../../core/config/app_config_provider.dart'; import '../../../core/constants.dart'; import '../../../core/theme/halo_tokens.dart'; -import '../../../core/theme/widgets/halo_popup.dart'; import '../../../core/theme/widgets/halo_snackbar.dart'; +import '../widgets/bestie_unavailable_dialog.dart'; import '../widgets/chat_expired_banner.dart'; import '../widgets/closing_message_sheet.dart'; import '../widgets/confirm_end_step1.dart'; @@ -169,13 +169,10 @@ class _ChatScreenState extends ConsumerState { if (_rejectPopupShown) return; _rejectPopupShown = true; if (!mounted) return; - // TODO(stage8): replace with BestieOfflinePopup variant: 'returning' - await HaloPopup.show( + await BestieOfflinePopup.show( context, - title: 'bestie lagi balik...', - body: 'sesi belum bisa ditutup karena bestie masih nyaut. coba lagi sebentar ya.', - icon: const Text('🔄', style: TextStyle(fontSize: 40)), - primary: HaloPopupAction(label: 'oke', onPressed: () {}), + variant: BestieOfflineVariant.returning, + mitraName: widget.mitraName, ); _rejectPopupShown = false; // Reset closure state so the user can retry without a stale-error block. diff --git a/client_app/lib/features/chat/screens/searching_screen.dart b/client_app/lib/features/chat/screens/searching_screen.dart index 2b9320b..ad9351e 100644 --- a/client_app/lib/features/chat/screens/searching_screen.dart +++ b/client_app/lib/features/chat/screens/searching_screen.dart @@ -70,10 +70,11 @@ class _SearchingScreenState extends ConsumerState { if (next is PairingTargetedUnavailableData && !_unavailableDialogShown) { _unavailableDialogShown = true; // ignore: discarded_futures - BestieUnavailableDialog.show( + BestieOfflinePopup.show( context, - paymentSessionId: next.paymentSessionId, + variant: BestieOfflineVariant.returning, mitraName: next.mitraName, + paymentSessionId: next.paymentSessionId, topicSensitivity: next.topicSensitivity, ).then((_) { if (mounted) _unavailableDialogShown = false; diff --git a/client_app/lib/features/chat/screens/targeted_waiting_screen.dart b/client_app/lib/features/chat/screens/targeted_waiting_screen.dart index 50f7316..a6a0f75 100644 --- a/client_app/lib/features/chat/screens/targeted_waiting_screen.dart +++ b/client_app/lib/features/chat/screens/targeted_waiting_screen.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import '../../../core/pairing/pairing_notifier.dart'; import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/widgets/widgets.dart'; +import '../widgets/bestie_unavailable_dialog.dart'; /// Phase 4 Stage 5 — `SWaitingBestie` overlay. /// @@ -16,9 +17,9 @@ import '../../../core/theme/widgets/widgets.dart'; /// The countdown is purely cosmetic; the server owns the auto-reject timer. /// - `accepted` (PairingBestieFoundData / PairingActiveData) — routes into /// the chat screen immediately. -/// - `declined` (PairingTargetedUnavailableData) — shows the bestie-offline -/// popup. TODO(stage8): swap this stub for the proper BestieOfflinePopup -/// component once Stage 8 lands. +/// - `declined` (PairingTargetedUnavailableData) — shows the +/// [BestieOfflinePopup] returning variant; the popup may offer a +/// fallback-to-blast CTA when other besties are reachable. class TargetedWaitingScreen extends ConsumerStatefulWidget { final String mitraId; const TargetedWaitingScreen({super.key, required this.mitraId}); @@ -58,21 +59,13 @@ class _TargetedWaitingScreenState extends ConsumerState { } if (next is PairingTargetedUnavailableData && !_popupShown) { _popupShown = true; - // TODO(stage8): replace stub with the production BestieOfflinePopup - // (Stage 8 owns the proper variant + fallback-to-blast surface). // ignore: discarded_futures - HaloPopup.show( + BestieOfflinePopup.show( context, - title: '${next.mitraName} lagi nggak online', - body: - 'bestie kamu belum bisa nerima chat sekarang. coba bestie lain atau balik ke beranda dulu ya.', - primary: HaloPopupAction( - label: 'kembali ke home', - onPressed: () { - ref.read(pairingProvider.notifier).reset(); - if (mounted) context.go('/home'); - }, - ), + variant: BestieOfflineVariant.returning, + mitraName: next.mitraName, + paymentSessionId: next.paymentSessionId, + topicSensitivity: next.topicSensitivity, ).then((_) { if (mounted) _popupShown = false; }); diff --git a/client_app/lib/features/chat/widgets/bestie_unavailable_dialog.dart b/client_app/lib/features/chat/widgets/bestie_unavailable_dialog.dart index ba9005d..1a61763 100644 --- a/client_app/lib/features/chat/widgets/bestie_unavailable_dialog.dart +++ b/client_app/lib/features/chat/widgets/bestie_unavailable_dialog.dart @@ -4,50 +4,56 @@ import 'package:go_router/go_router.dart'; import '../../../core/availability/mitra_availability_notifier.dart'; import '../../../core/constants.dart'; import '../../../core/pairing/pairing_notifier.dart'; +import '../../../core/theme/halo_tokens.dart'; +import '../../../core/theme/widgets/widgets.dart'; +import '../../support/widgets/tanya_admin_sheet.dart'; -/// Shown when a "Curhat lagi" attempt against a specific bestie can't proceed -/// — either a 409 `targeted_mitra_offline` response on the targeted POST, or -/// one of the intermediate WS events (`returning_chat_timeout`, -/// `returning_chat_rejected`). +/// Phase 4 Stage 8 — `BestieOfflinePopup`. /// -/// CTAs: -/// - "Chat dengan bestie lain" — only rendered when -/// [mitraAvailabilityProvider] reports `available == true` at the time of -/// build. Tapping calls [Pairing.fallbackToBlast] (reuses the same payment -/// session — no double-charge) and closes the dialog. The caller is expected -/// to be the searching screen, which will transition into PairingSearchingData -/// and stay put. -/// - "Kembali" — pops dialog and routes home. Backend has already audit-logged -/// the targeted failure; payment session stays `confirmed` until the sweeper -/// expires it. -class BestieUnavailableDialog extends ConsumerWidget { - final String paymentSessionId; - final String mitraName; - final TopicSensitivity topicSensitivity; +/// Two variants: +/// - [BestieOfflineVariant.returning] — the customer tried to chat with a +/// specific mitra (history "Curhat lagi"); the targeted attempt failed +/// (409 `targeted_mitra_offline`, or WS `returning_chat_timeout` / +/// `returning_chat_rejected`). Payment session is still `confirmed`, so we +/// surface a `Chat dengan bestie lain` primary CTA when other besties are +/// reachable (calls [Pairing.fallbackToBlast]). +/// - [BestieOfflineVariant.new_] — the customer triggered a general blast +/// that bottomed out (no online besties). No fallback button; just a +/// ghost `tanya admin` and a `kembali ke home` exit. +/// +/// Both variants expose `tanya admin` via a ghost CTA that opens the +/// [TanyaAdminSheet]. +enum BestieOfflineVariant { returning, new_ } - const BestieUnavailableDialog({ +class BestieOfflinePopup extends ConsumerWidget { + final BestieOfflineVariant variant; + final String mitraName; + final String? paymentSessionId; + final TopicSensitivity? topicSensitivity; + + const BestieOfflinePopup({ super.key, - required this.paymentSessionId, + required this.variant, required this.mitraName, - required this.topicSensitivity, + this.paymentSessionId, + this.topicSensitivity, }); - /// Convenience: show this dialog and return when it closes. Per project - /// memory ("Riverpod ref.listen in build is unsafe"), callers should - /// invoke this from `ref.listenManual` callbacks in `initState`, not from - /// `build`. static Future show( BuildContext context, { - required String paymentSessionId, + required BestieOfflineVariant variant, required String mitraName, - required TopicSensitivity topicSensitivity, + String? paymentSessionId, + TopicSensitivity? topicSensitivity, }) { return showDialog( context: context, barrierDismissible: false, - builder: (_) => BestieUnavailableDialog( - paymentSessionId: paymentSessionId, + barrierColor: const Color(0x66000000), + builder: (_) => BestieOfflinePopup( + variant: variant, mitraName: mitraName, + paymentSessionId: paymentSessionId, topicSensitivity: topicSensitivity, ), ); @@ -55,44 +61,122 @@ class BestieUnavailableDialog extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // Snapshot at dialog-open time — we don't keep listening, we just check - // whether other bestie are around right now. final availabilityAsync = ref.watch(mitraAvailabilityProvider); final hasOtherAvailable = availabilityAsync.valueOrNull ?? false; - return AlertDialog( - title: const Text('Bestie sedang tidak online'), - content: Text( - '$mitraName sedang tidak bisa menerima chat saat ini. ' - 'Kamu bisa coba chat dengan bestie lain atau kembali ke beranda.', - ), - actions: [ - TextButton( - onPressed: () { - // Reset pairing state and route home. Payment session stays - // confirmed until sweeper expires it — no extra API call needed. - ref.read(pairingProvider.notifier).reset(); - Navigator.of(context).pop(); - context.go('/home'); - }, - child: const Text('Kembali'), + final isReturning = variant == BestieOfflineVariant.returning; + final title = isReturning ? '$mitraName lagi nggak online' : 'semua bestie lagi istirahat'; + final body = isReturning + ? 'bestie kamu belum bisa nerima chat sekarang. coba bestie lain atau balik ke beranda dulu ya.' + : 'lagi nggak ada bestie yang siap dengerin. coba lagi bentar, atau hubungin admin biar dibantu.'; + + final canFallbackToBlast = isReturning && + hasOtherAvailable && + paymentSessionId != null && + topicSensitivity != null; + + return Dialog( + backgroundColor: HaloTokens.surface, + elevation: 0, + shape: const RoundedRectangleBorder(borderRadius: HaloRadius.xl), + insetPadding: const EdgeInsets.symmetric(horizontal: HaloSpacing.s24), + child: Padding( + padding: const EdgeInsets.all(HaloSpacing.s24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 64, + height: 64, + decoration: const BoxDecoration( + color: HaloTokens.brandSofter, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: const Icon( + Icons.cloud_off_outlined, + color: HaloTokens.brandDark, + size: 28, + ), + ), + ), + const SizedBox(height: HaloSpacing.s16), + Text( + title, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 22, + height: 28 / 22, + fontWeight: FontWeight.w700, + color: HaloTokens.ink, + ), + ), + const SizedBox(height: HaloSpacing.s12), + Text( + body, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 15, + height: 22 / 15, + color: HaloTokens.inkSoft, + ), + ), + const SizedBox(height: HaloSpacing.s24), + if (canFallbackToBlast) + HaloButton( + label: 'chat dengan bestie lain', + fullWidth: true, + onPressed: () { + Navigator.of(context).pop(); + // ignore: discarded_futures + ref.read(pairingProvider.notifier).fallbackToBlast( + paymentSessionId: paymentSessionId!, + topicSensitivity: topicSensitivity!, + ); + }, + ) + else + HaloButton( + label: 'kembali ke home', + fullWidth: true, + onPressed: () { + ref.read(pairingProvider.notifier).reset(); + Navigator.of(context).pop(); + context.go('/home'); + }, + ), + const SizedBox(height: HaloSpacing.s8), + HaloButton( + label: 'tanya admin', + variant: HaloButtonVariant.ghost, + fullWidth: true, + onPressed: () { + // Keep the popup open underneath; the sheet sits on top and + // closes back to it. + // ignore: discarded_futures + TanyaAdminSheet.show(context); + }, + ), + if (canFallbackToBlast) ...[ + const SizedBox(height: HaloSpacing.s4), + HaloButton( + label: 'kembali ke home', + variant: HaloButtonVariant.ghost, + fullWidth: true, + onPressed: () { + ref.read(pairingProvider.notifier).reset(); + Navigator.of(context).pop(); + context.go('/home'); + }, + ), + ], + ], ), - if (hasOtherAvailable) - ElevatedButton( - onPressed: () { - // Close the dialog first, then kick off the fallback. The - // searching screen will pick up the new PairingSearchingData - // state and render normally (no targeted overlay). - Navigator.of(context).pop(); - // ignore: discarded_futures - ref.read(pairingProvider.notifier).fallbackToBlast( - paymentSessionId: paymentSessionId, - topicSensitivity: topicSensitivity, - ); - }, - child: const Text('Chat dengan bestie lain'), - ), - ], + ), ); } } diff --git a/client_app/lib/features/home/home_screen.dart b/client_app/lib/features/home/home_screen.dart index 054bb55..b921c06 100644 --- a/client_app/lib/features/home/home_screen.dart +++ b/client_app/lib/features/home/home_screen.dart @@ -6,6 +6,8 @@ import '../../core/availability/mitra_availability_notifier.dart'; import '../../core/chat/active_session_notifier.dart'; import '../../core/notifications/notif_permission.dart'; import '../../core/theme/halo_tokens.dart'; +import 'providers/bestie_history_provider.dart'; +import 'widgets/bestie_choice_sheet.dart'; /// Session-only dismiss flag for the "notif denied" banner. Resets on cold /// restart by design — `StateProvider` lives in memory only. @@ -58,16 +60,27 @@ class _HomeScreenState extends ConsumerState with WidgetsBindingObse } } - void _onStartChatPressed(BuildContext context) { + Future _onStartChatPressed(BuildContext context) async { // Phase 4 Stage 2 removes the home-screen topic sensitivity prompt; the // ESP picks collected during onboarding feed the same column server-side // (info-only — no longer drives matching). Mitras still flip // `topic_sensitivity` mid-session via the AppBar toggle. // - // Phase 4 Stage 3: enter the new multi-screen payment shell. The entry - // route picks discount-paywall vs. method-pick based on first-session - // eligibility. The legacy `/payment` route is preserved for the - // chat-history "Curhat lagi" path until Stage 5 migrates it. + // Phase 4 Stage 8: returning users get the bestie-choice sheet first; new + // users skip straight to the multi-screen payment shell. We fetch the + // history-has-items flag on-tap so a stale cache from logout/login doesn't + // mis-route. On error (e.g. offline), fall back to the new-user path. + bool hasHistory; + try { + hasHistory = await ref.read(bestieHistoryHasItemsProvider.future); + } catch (_) { + hasHistory = false; + } + if (!context.mounted) return; + if (hasHistory) { + await BestieChoiceSheet.show(context); + return; + } context.push('/payment/entry'); } diff --git a/client_app/lib/features/home/providers/bestie_history_provider.dart b/client_app/lib/features/home/providers/bestie_history_provider.dart new file mode 100644 index 0000000..74625f7 --- /dev/null +++ b/client_app/lib/features/home/providers/bestie_history_provider.dart @@ -0,0 +1,50 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/api/api_client_provider.dart'; + +class BestieHistoryItem { + final String sessionId; + final String? mitraId; + final String mitraName; + final DateTime? endedAt; + final List topics; + final int sessionsCount; + final bool mitraIsOnline; + + const BestieHistoryItem({ + required this.sessionId, + required this.mitraId, + required this.mitraName, + required this.endedAt, + required this.topics, + required this.sessionsCount, + required this.mitraIsOnline, + }); + + factory BestieHistoryItem.fromJson(Map json) { + final endedAtRaw = json['ended_at']; + return BestieHistoryItem( + sessionId: json['id'] as String, + mitraId: json['mitra_id'] as String?, + mitraName: json['mitra_display_name'] as String? ?? 'Bestie', + endedAt: endedAtRaw is String ? DateTime.tryParse(endedAtRaw)?.toLocal() : null, + topics: (json['topics'] as List?)?.cast() ?? const [], + sessionsCount: (json['sessions_count'] as num?)?.toInt() ?? 1, + mitraIsOnline: json['mitra_is_online'] as bool? ?? false, + ); + } +} + +final bestieHistoryProvider = FutureProvider>((ref) async { + final api = ref.read(apiClientProvider); + final response = await api.get('/api/client/chat/history'); + final items = (response['data']['items'] as List? ?? []) + .cast>(); + return items.map(BestieHistoryItem.fromJson).toList(); +}); + +/// Cheap derived provider used by the home CTA to decide whether to show the +/// bestie-choice sheet or skip straight into the new-payment flow. +final bestieHistoryHasItemsProvider = FutureProvider((ref) async { + final items = await ref.watch(bestieHistoryProvider.future); + return items.isNotEmpty; +}); diff --git a/client_app/lib/features/home/widgets/bestie_choice_sheet.dart b/client_app/lib/features/home/widgets/bestie_choice_sheet.dart new file mode 100644 index 0000000..8867354 --- /dev/null +++ b/client_app/lib/features/home/widgets/bestie_choice_sheet.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/theme/halo_tokens.dart'; +import '../../../core/theme/widgets/widgets.dart'; + +/// Phase 4 Stage 8 — Bestie Choice Sheet. +/// +/// Triggered from the home `Mulai Curhat` CTA when the user has at least one +/// prior session. Two cards: continue with a known bestie (→ history list) +/// vs. find a new bestie (→ soft-prompt + blast). +class BestieChoiceSheet extends StatelessWidget { + const BestieChoiceSheet({super.key}); + + static Future show(BuildContext context) { + return HaloBottomSheet.show( + context, + child: const BestieChoiceSheet(), + ); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'mau curhat sama siapa?', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 22, + fontWeight: FontWeight.w700, + color: HaloTokens.ink, + ), + ), + const SizedBox(height: HaloSpacing.s8), + const Text( + 'pilih lanjut sama bestie yang udah kenal, atau coba bestie baru.', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 14, + color: HaloTokens.inkSoft, + ), + ), + const SizedBox(height: HaloSpacing.s24), + _ChoiceCard( + title: 'bestie yang udah kenal', + subtitle: 'lanjut cerita ke bestie yang pernah dengerin kamu.', + icon: Icons.favorite_outline, + onTap: () { + Navigator.of(context).pop(); + context.push('/chat/history'); + }, + ), + const SizedBox(height: HaloSpacing.s12), + _ChoiceCard( + title: 'bestie baru', + subtitle: 'cari bestie baru yang siap dengerin sekarang.', + icon: Icons.auto_awesome_outlined, + onTap: () { + Navigator.of(context).pop(); + context.push('/payment/entry'); + }, + ), + ], + ); + } +} + +class _ChoiceCard extends StatelessWidget { + final String title; + final String subtitle; + final IconData icon; + final VoidCallback onTap; + + const _ChoiceCard({ + required this.title, + required this.subtitle, + required this.icon, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: HaloTokens.brandSofter, + borderRadius: HaloRadius.lg, + child: InkWell( + onTap: onTap, + borderRadius: HaloRadius.lg, + child: Padding( + padding: const EdgeInsets.all(HaloSpacing.s16), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: const BoxDecoration( + color: HaloTokens.brandSoft, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Icon(icon, color: HaloTokens.brandDark, size: 24), + ), + const SizedBox(width: HaloSpacing.s12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 16, + fontWeight: FontWeight.w700, + color: HaloTokens.ink, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 13, + height: 18 / 13, + color: HaloTokens.inkSoft, + ), + ), + ], + ), + ), + const Icon(Icons.chevron_right, color: HaloTokens.brandDark), + ], + ), + ), + ), + ); + } +} diff --git a/client_app/lib/features/support/providers/support_handles_provider.dart b/client_app/lib/features/support/providers/support_handles_provider.dart new file mode 100644 index 0000000..5d27914 --- /dev/null +++ b/client_app/lib/features/support/providers/support_handles_provider.dart @@ -0,0 +1,33 @@ +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 json) => + SupportHandle( + label: json['label'] as String? ?? '', + deeplink: json['deeplink'] as String? ?? '', + ); +} + +class SupportHandles { + final SupportHandle? wa; + final SupportHandle? telegram; + const SupportHandles({this.wa, this.telegram}); + + factory SupportHandles.fromJson(Map json) { + SupportHandle? parse(dynamic v) => + v is Map ? SupportHandle.fromJson(v) : null; + return SupportHandles(wa: parse(json['wa']), telegram: parse(json['telegram'])); + } +} + +final supportHandlesProvider = FutureProvider((ref) async { + final api = ref.read(apiClientProvider); + final response = await api.get('/api/client/support-handles'); + final data = response['data'] as Map? ?? const {}; + return SupportHandles.fromJson(data); +}); diff --git a/client_app/lib/features/support/widgets/tanya_admin_sheet.dart b/client_app/lib/features/support/widgets/tanya_admin_sheet.dart new file mode 100644 index 0000000..3f06997 --- /dev/null +++ b/client_app/lib/features/support/widgets/tanya_admin_sheet.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../../../core/theme/halo_tokens.dart'; +import '../../../core/theme/widgets/widgets.dart'; +import '../providers/support_handles_provider.dart'; + +/// Phase 4 Stage 8 — Tanya Admin sheet. +/// +/// Reads handles from `supportHandlesProvider` (CC-config-driven) and surfaces +/// WA + Telegram deeplinks. Tap → `url_launcher` external app. No webview. +class TanyaAdminSheet extends ConsumerWidget { + const TanyaAdminSheet({super.key}); + + static Future show(BuildContext context) { + return HaloBottomSheet.show( + context, + child: const TanyaAdminSheet(), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final handlesAsync = ref.watch(supportHandlesProvider); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'tanya admin', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 22, + fontWeight: FontWeight.w700, + color: HaloTokens.ink, + ), + ), + const SizedBox(height: HaloSpacing.s8), + const Text( + 'pilih cara yang paling enak buat kamu — admin bakal balas secepatnya.', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 14, + color: HaloTokens.inkSoft, + ), + ), + const SizedBox(height: HaloSpacing.s24), + handlesAsync.when( + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: HaloSpacing.s24), + child: Center(child: CircularProgressIndicator()), + ), + error: (_, __) => Padding( + padding: const EdgeInsets.symmetric(vertical: HaloSpacing.s16), + child: Column( + children: [ + const Text( + 'gagal mengambil kontak admin. coba lagi sebentar.', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 13, + color: HaloTokens.danger, + ), + ), + const SizedBox(height: HaloSpacing.s12), + HaloButton( + label: 'coba lagi', + variant: HaloButtonVariant.secondary, + fullWidth: true, + onPressed: () => ref.invalidate(supportHandlesProvider), + ), + ], + ), + ), + data: (handles) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (handles.wa != null) + HaloButton( + label: handles.wa!.label.isEmpty ? 'WhatsApp' : handles.wa!.label, + variant: HaloButtonVariant.primary, + fullWidth: true, + icon: const Icon(Icons.chat_bubble_outline), + onPressed: () => _launch(context, handles.wa!.deeplink), + ), + if (handles.wa != null && handles.telegram != null) + const SizedBox(height: HaloSpacing.s12), + if (handles.telegram != null) + HaloButton( + label: handles.telegram!.label.isEmpty + ? 'Telegram' + : handles.telegram!.label, + variant: HaloButtonVariant.secondary, + fullWidth: true, + icon: const Icon(Icons.send_outlined), + onPressed: () => _launch(context, handles.telegram!.deeplink), + ), + if (handles.wa == null && handles.telegram == null) + const Padding( + padding: EdgeInsets.symmetric(vertical: HaloSpacing.s16), + child: Text( + 'kontak admin belum tersedia. coba lagi nanti ya.', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 13, + color: HaloTokens.inkSoft, + ), + ), + ), + ], + ), + ), + ], + ); + } + + Future _launch(BuildContext context, String deeplink) async { + final uri = Uri.tryParse(deeplink); + if (uri == null) return; + final ok = await launchUrl(uri, mode: LaunchMode.externalApplication); + if (!ok && context.mounted) { + // Fall back to platform default if external launch refused. + await launchUrl(uri); + } + } +} diff --git a/client_app/linux/flutter/generated_plugin_registrant.cc b/client_app/linux/flutter/generated_plugin_registrant.cc index d0e7f79..38dd0bc 100644 --- a/client_app/linux/flutter/generated_plugin_registrant.cc +++ b/client_app/linux/flutter/generated_plugin_registrant.cc @@ -7,9 +7,13 @@ #include "generated_plugin_registrant.h" #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/client_app/linux/flutter/generated_plugins.cmake b/client_app/linux/flutter/generated_plugins.cmake index ce58916..7e7bd77 100644 --- a/client_app/linux/flutter/generated_plugins.cmake +++ b/client_app/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_linux + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/client_app/macos/Flutter/GeneratedPluginRegistrant.swift b/client_app/macos/Flutter/GeneratedPluginRegistrant.swift index aba5109..7e6f540 100644 --- a/client_app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/client_app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -12,6 +12,7 @@ import flutter_secure_storage_macos import google_sign_in_ios import shared_preferences_foundation import sign_in_with_apple +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) @@ -21,4 +22,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/client_app/pubspec.lock b/client_app/pubspec.lock index e2c7632..38fd631 100644 --- a/client_app/pubspec.lock +++ b/client_app/pubspec.lock @@ -1157,6 +1157,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: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572" + url: "https://pub.dev" + source: hosted + version: "6.3.29" + 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: diff --git a/client_app/pubspec.yaml b/client_app/pubspec.yaml index 8510085..5a98738 100644 --- a/client_app/pubspec.yaml +++ b/client_app/pubspec.yaml @@ -46,6 +46,10 @@ dependencies: # (Phase 4 Stage 4) and the home banner. permission_handler: ^11.3.1 + # External URL launching — Tanya Admin sheet (Phase 4 Stage 8) opens + # WhatsApp / Telegram via https deeplinks from CC-config-driven handles. + url_launcher: ^6.3.0 + dev_dependencies: flutter_test: sdk: flutter diff --git a/client_app/windows/flutter/generated_plugin_registrant.cc b/client_app/windows/flutter/generated_plugin_registrant.cc index 0433e3c..fc5a497 100644 --- a/client_app/windows/flutter/generated_plugin_registrant.cc +++ b/client_app/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FirebaseCorePluginCApiRegisterWithRegistrar( @@ -17,4 +18,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/client_app/windows/flutter/generated_plugins.cmake b/client_app/windows/flutter/generated_plugins.cmake index de4a7d2..19a7721 100644 --- a/client_app/windows/flutter/generated_plugins.cmake +++ b/client_app/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST firebase_core flutter_secure_storage_windows permission_handler_windows + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST