Files
halobestie-clone/client_app/lib/features/onboarding/onboarding_screen.dart
ramadhan sjamsani a09f37135c Phase 4 checkpoint: chat-screen perf refactor + retryable blast-failure + repo-wide dispose-ref guardrail
Chat-screen performance (customer + mitra):
- Parent screens have zero `ref.watch` — only `ref.listen` for side effects
- Body extracted into its own `ConsumerStatefulWidget`; AppBar parts split
  into narrow `.select` consumers (mode, sensitivity, timer)
- Per-second timer ticks routed to dedicated providers
  (`chatRemainingSecondsProvider` + new `mitraChatRemainingSecondsProvider`)
  so WS `session_tick` frames don't invalidate the rest of the chat state

Dispose-in-ref bug fix:
- `home_screen.dart`, `payment_screen.dart`, `mitra_chat_screen.dart` —
  ref-using cleanup moved from `dispose()` to `deactivate()`. Modern
  Riverpod invalidates `ref` the moment `dispose()` runs; the resulting
  silent error corrupts the widget-tree finalize and the next screen
  appears frozen
- `halo_lints` package added at repo root with `no_ref_in_dispose` rule
  to catch this pattern in CI / IDE analysis
- `custom_lint` activated in both apps' `analysis_options.yaml`
  (was installed but never wired in — also brings `riverpod_lint`'s
  `avoid_ref_inside_state_dispose` online)
- CLAUDE.md Pitfalls section added to client_app + mitra_app

Phase 4 §3 retryable blast-failure (Option A):
- Backend `expirePairingRequest` + all-rejected use
  `recordIntermediateFailure` instead of `failPaymentSession` so the
  payment session stays `confirmed` for re-blast
- WS `pairing_failed` payload carries `is_terminal: false` on the
  retryable paths; client parses the flag and exposes `retryBlast()`
- "Coba cari lagi" CTA on S7 Timeout now re-blasts on the same payment
- Pairing service test updated to reflect the new semantics

Customer waiting-payment screen navigation patch:
- `_navigateTerminal` uses `Future.microtask` + `addPostFrameCallback`
  redundancy after a release-mode bug where polling stopped but
  `context.go` never fired, leaving the screen visually stuck on
  "menunggu pembayaran"

See requirement/resume-2026-05-15.md for next-day pickup checklist
(mitra release rebuild + S21 Ultra install + retest is the gating item).

Bundles unrelated in-flight Phase 4 §2.x work that was already on disk
(ESP screen removal, USP one-time gate scaffolding, bestie-availability
public route, OTP service edits, Maestro flow tweaks) — kept together
to avoid a partial-rebase mess.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:12:34 +08:00

211 lines
6.1 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../router.dart';
const _kOnboardingDone = 'onboarding_done';
const _kPink = Color(0xFFBE7C8A);
class _OnboardingPage {
final String title;
final String text;
final String image;
const _OnboardingPage({
required this.title,
required this.text,
required this.image,
});
}
const _pages = [
_OnboardingPage(
title: 'Langsung Curhat',
text: 'Tidak perlu form panjang atau janji. Masuk dan langsung ngobrol.',
image: 'assets/images/splash/splash_1.png',
),
_OnboardingPage(
title: '100% Anonim',
text: 'Identitas kamu tidak akan ditampilkan. Cerita dengan tenang, tanpa khawatir.',
image: 'assets/images/splash/splash_2.png',
),
_OnboardingPage(
title: 'Bestie yang Relevan',
text: 'Kamu akan dipasangkan dengan bestie berdasarkan topik & kondisi kamu saat ini.',
image: 'assets/images/splash/splash_3.png',
),
];
class OnboardingScreen extends ConsumerStatefulWidget {
const OnboardingScreen({super.key});
@override
ConsumerState<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
final _controller = PageController();
int _currentPage = 0;
@override
void initState() {
super.initState();
// Auto-advance: page 0 → 1 after 500ms
_scheduleAutoAdvance(0);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _scheduleAutoAdvance(int fromPage) {
// Only auto-advance for pages 0 and 1
if (fromPage >= 2) return;
Future.delayed(const Duration(seconds: 1), () {
if (mounted && _currentPage == fromPage) {
_controller.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
});
}
void _onPageChanged(int index) {
setState(() => _currentPage = index);
_scheduleAutoAdvance(index);
}
Future<void> _finish() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kOnboardingDone, true);
ref.invalidate(onboardingDoneProvider);
if (mounted) {
context.go('/home');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: Column(
children: [
Expanded(
child: PageView.builder(
controller: _controller,
itemCount: _pages.length,
onPageChanged: _onPageChanged,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
final page = _pages[index];
return _buildPage(page);
},
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
child: Row(
children: [
// Page indicators
Row(
children: List.generate(_pages.length, (index) {
final isActive = index == _currentPage;
return Container(
margin: const EdgeInsets.only(right: 8),
width: isActive ? 32 : 12,
height: 6,
decoration: BoxDecoration(
color: isActive ? _kPink : _kPink.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(3),
),
);
}),
),
const Spacer(),
// CTA button — only show "Mulai" on last page
if (_currentPage == _pages.length - 1)
GestureDetector(
onTap: _finish,
child: Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 32),
decoration: BoxDecoration(
color: _kPink,
borderRadius: BorderRadius.circular(16),
),
child: const Center(
child: Text(
'Mulai',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
),
],
),
),
],
),
),
);
}
Widget _buildPage(_OnboardingPage page) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
const Spacer(flex: 1),
// Image
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Image.asset(
page.image,
height: 280,
fit: BoxFit.contain,
),
),
const Spacer(flex: 1),
// Title
Text(
page.title,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: _kPink,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
// Description
Text(
page.text,
style: TextStyle(
fontSize: 16,
color: Colors.pink.shade300,
height: 1.5,
),
textAlign: TextAlign.center,
),
const Spacer(flex: 1),
],
),
);
}
}
/// Check if onboarding has been completed
Future<bool> isOnboardingDone() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_kOnboardingDone) ?? false;
}