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) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 17:47:02 +08:00
parent d454fd39db
commit 862fc35a40
23 changed files with 1122 additions and 215 deletions

View File

@@ -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<String, dynamic> 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<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/client/support-handles');
final data = response['data'] as Map<String, dynamic>? ?? const {};
return SupportHandles.fromJson(data);
});

View File

@@ -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<void> show(BuildContext context) {
return HaloBottomSheet.show<void>(
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<void> _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);
}
}
}