Files
halobestie-clone/client_app/lib/features/home/home_screen.dart
ramadhan sjamsani d09e50af55 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>
2026-05-03 23:02:49 +08:00

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),
],
),
),
),
);
}
}