Phase 3.7: paid pairing flow + returning chat + extension flip
- Backend: payment_sessions + pairing_failures tables; payment.service.js and pairing-failure.service.js (new); rewritten pairing.service.js (payment-gated blast + targeted "Curhat lagi" + cancel + fallback); rewritten extension.service.js (data-driven auto-approve with offline safeguard, charge-at-approval); pricing.service.js (extension tiers without free trial); mitra-status.service.js (countAvailableMitras cached path); 60s sweeper for stale payment sessions - Backend routes: client.payment.routes, client.mitra-availability.routes, internal/failed-pairings.routes; client.chat.routes rewritten for payment-gated start + /returning + /cancel + /fallback-to-blast; internal/config.routes adds 4 new keys with Valkey invalidate publish - client_app: mitra-availability poll, payment screen + notifier, pairing notifier rewrite (PairingTargetedWaiting + PairingFailed states), targeted-waiting overlay + bestie-unavailable dialog, "Curhat lagi" CTA, failed-pairing terminal, extension via payment-session - mitra_app: PairingRequestType enum, returning-chat 20s countdown auto-dismiss, extension card "otomatis disetujui" copy - control_center: 4 new config rows in Settings, Failed Pairings page (filter + paginate + action menu), sidebar + route registered - Test infrastructure: Vitest backend (7/7 pass), Playwright CC (4/4 pass), Maestro mobile scaffold (CLI install pending) - Bugs found via Playwright + fixed: LoginPage labels not associated with inputs (a11y); backend internal CORS missing PATCH/PUT/DELETE in allow-methods (silent settings breakage in browsers since Stage 4) - Docs: phase3.7.md PRD, phase3.7-plan.md, phase3.7-questions.md (Q&A), phase3.7-testing.md (E2E checklist), phase3.7-test-run-2026-05-03.md (today's run results) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,11 +2,17 @@ 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/availability/mitra_availability_notifier.dart';
|
||||
import '../../core/chat/active_session_notifier.dart';
|
||||
import '../../core/pairing/pairing_notifier.dart';
|
||||
import '../chat/widgets/pricing_bottom_sheet.dart';
|
||||
import '../chat/widgets/topic_selection_bottom_sheet.dart';
|
||||
|
||||
/// Home screen.
|
||||
///
|
||||
/// 1. The "Mulai Curhat" CTA is gated on real-time mitra availability
|
||||
/// (polling owned by the [mitraAvailabilityProvider]). Polling is paused
|
||||
/// on background and resumed on foreground via [WidgetsBindingObserver].
|
||||
/// 2. Tapping the enabled CTA pushes `/payment` so the customer must confirm
|
||||
/// a payment session before any blast fires.
|
||||
class HomeScreen extends ConsumerStatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@@ -19,26 +25,38 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
// Kick the availability poll on once the first frame settles. Doing it
|
||||
// here (rather than in build) avoids re-firing on every rebuild.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
ref.read(mitraAvailabilityProvider.notifier).setActive(true);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Stop polling when leaving home.
|
||||
ref.read(mitraAvailabilityProvider.notifier).setActive(false);
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
final notifier = ref.read(mitraAvailabilityProvider.notifier);
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
// Re-fetch in case a session ended/started while backgrounded.
|
||||
ref.read(activeSessionProvider.notifier).refresh();
|
||||
notifier.setActive(true);
|
||||
} else if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive) {
|
||||
notifier.setActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onStartChatPressed(BuildContext context) async {
|
||||
final topic = await TopicSelectionBottomSheet.show(context);
|
||||
if (topic == null || !context.mounted) return;
|
||||
await PricingBottomSheet.show(context, topicSensitivity: topic);
|
||||
context.push('/payment', extra: {'topicSensitivity': topic});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -46,6 +64,7 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
|
||||
final authState = ref.watch(authProvider);
|
||||
final authData = authState.valueOrNull;
|
||||
final activeSessionAsync = ref.watch(activeSessionProvider);
|
||||
final availabilityAsync = ref.watch(mitraAvailabilityProvider);
|
||||
|
||||
final displayName = switch (authData) {
|
||||
AuthAuthenticatedData d => d.profile['display_name'] as String? ?? '',
|
||||
@@ -53,76 +72,84 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
|
||||
_ => '',
|
||||
};
|
||||
|
||||
ref.listen(pairingProvider, (prev, next) {
|
||||
if (next is PairingSearchingData) {
|
||||
context.go('/chat/searching');
|
||||
} else if (next is PairingNoBestieData) {
|
||||
context.go('/chat/no-bestie');
|
||||
} else if (next is PairingErrorData) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(next.message)),
|
||||
);
|
||||
}
|
||||
});
|
||||
// Poll-failure / loading both default to "no bestie available" (greyed-out).
|
||||
// Never optimistically enable.
|
||||
final mitraAvailable = availabilityAsync.valueOrNull ?? false;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Halo Bestie'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.history),
|
||||
onPressed: () => context.push('/chat/history'),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout),
|
||||
onPressed: () => ref.read(authProvider.notifier).logout(),
|
||||
appBar: AppBar(
|
||||
title: const Text('Halo Bestie'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.history),
|
||||
onPressed: () => context.push('/chat/history'),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout),
|
||||
onPressed: () => ref.read(authProvider.notifier).logout(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
// Pull-to-refresh kicks both the active-session and availability polls.
|
||||
await Future.wait([
|
||||
ref.read(activeSessionProvider.notifier).refresh(),
|
||||
ref.read(mitraAvailabilityProvider.notifier).refresh(),
|
||||
]);
|
||||
},
|
||||
child: ListView(
|
||||
// Force-scroll so RefreshIndicator can fire even on a short body.
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(32),
|
||||
children: [
|
||||
const SizedBox(height: 32),
|
||||
Center(child: Text('Halo, $displayName!', style: const TextStyle(fontSize: 24))),
|
||||
const SizedBox(height: 32),
|
||||
Center(
|
||||
child: activeSessionAsync.when(
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (_, __) => _StartChatButton(
|
||||
enabled: mitraAvailable,
|
||||
onPressed: () => _onStartChatPressed(context),
|
||||
),
|
||||
data: (snapshot) {
|
||||
// Hide the "Sesi Aktif" CTA when the session is in `closing`
|
||||
// — the conversation is over, only the goodbye composer
|
||||
// remains. Backend auto-completes such sessions after a
|
||||
// grace period; until then the user shouldn't be invited
|
||||
// back into them from home.
|
||||
final status = snapshot.session?['status'] as String?;
|
||||
final isCurhatable = snapshot.hasSession && status != 'closing';
|
||||
if (isCurhatable) {
|
||||
return _ActiveSessionCard(
|
||||
mitraName: snapshot.mitraName,
|
||||
unreadCount: snapshot.unreadCount,
|
||||
onTap: () {
|
||||
final sessionId = snapshot.sessionId;
|
||||
if (sessionId == null) return;
|
||||
context.push('/chat/session/$sessionId', extra: snapshot.mitraName);
|
||||
},
|
||||
);
|
||||
}
|
||||
return _StartChatButton(
|
||||
enabled: mitraAvailable,
|
||||
onPressed: () => _onStartChatPressed(context),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)),
|
||||
const SizedBox(height: 32),
|
||||
activeSessionAsync.when(
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (_, __) => _StartChatButton(onPressed: () => _onStartChatPressed(context)),
|
||||
data: (snapshot) {
|
||||
// Hide the "Sesi Aktif" CTA when the session is in `closing`
|
||||
// — the conversation is over, only the goodbye composer
|
||||
// remains. Backend auto-completes such sessions after a
|
||||
// grace period; until then the user shouldn't be invited
|
||||
// back into them from home.
|
||||
final status = snapshot.session?['status'] as String?;
|
||||
final isCurhatable = snapshot.hasSession && status != 'closing';
|
||||
if (isCurhatable) {
|
||||
return _ActiveSessionCard(
|
||||
mitraName: snapshot.mitraName,
|
||||
unreadCount: snapshot.unreadCount,
|
||||
onTap: () {
|
||||
final sessionId = snapshot.sessionId;
|
||||
if (sessionId == null) return;
|
||||
context.push('/chat/session/$sessionId', extra: snapshot.mitraName);
|
||||
},
|
||||
);
|
||||
}
|
||||
return _StartChatButton(onPressed: () => _onStartChatPressed(context));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StartChatButton extends StatelessWidget {
|
||||
final bool enabled;
|
||||
final VoidCallback onPressed;
|
||||
const _StartChatButton({required this.onPressed});
|
||||
const _StartChatButton({required this.enabled, required this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -133,9 +160,15 @@ class _StartChatButton extends StatelessWidget {
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
onPressed: enabled ? onPressed : null,
|
||||
child: const Text('Mulai Curhat', style: TextStyle(fontSize: 18)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (!enabled)
|
||||
Text(
|
||||
'Belum ada bestie tersedia',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user