- 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>
232 lines
8.0 KiB
Dart
232 lines
8.0 KiB
Dart
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 '../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});
|
|
|
|
@override
|
|
ConsumerState<HomeScreen> createState() => _HomeScreenState();
|
|
}
|
|
|
|
class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObserver {
|
|
@override
|
|
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;
|
|
context.push('/payment', extra: {'topicSensitivity': topic});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
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? ?? '',
|
|
AuthAnonymousData d => d.displayName,
|
|
_ => '',
|
|
};
|
|
|
|
// 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(),
|
|
),
|
|
],
|
|
),
|
|
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),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StartChatButton extends StatelessWidget {
|
|
final bool enabled;
|
|
final VoidCallback onPressed;
|
|
const _StartChatButton({required this.enabled, required this.onPressed});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
children: [
|
|
const SizedBox(height: 16),
|
|
ElevatedButton(
|
|
style: ElevatedButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16),
|
|
),
|
|
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),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ActiveSessionCard extends StatelessWidget {
|
|
final String mitraName;
|
|
final int unreadCount;
|
|
final VoidCallback onTap;
|
|
|
|
const _ActiveSessionCard({
|
|
required this.mitraName,
|
|
required this.unreadCount,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Card(
|
|
elevation: 2,
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Row(
|
|
children: [
|
|
Badge(
|
|
isLabelVisible: unreadCount > 0,
|
|
label: Text('$unreadCount'),
|
|
child: const CircleAvatar(
|
|
backgroundColor: Colors.green,
|
|
child: Icon(Icons.chat, color: Colors.white),
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Sesi Aktif',
|
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Sedang curhat dengan $mitraName',
|
|
style: const TextStyle(fontSize: 14, color: Colors.grey),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Icon(Icons.chevron_right),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|