Phase 3.1 WIP: Riverpod migration (client_app Auth + ChatOpening)

- Add phase3.1 requirement and implementation plan docs
- Add Riverpod dependencies to both client_app and mitra_app
- Wrap both app roots with ProviderScope
- Migrate client_app AuthBloc → AuthNotifier (@riverpod annotation)
- Migrate client_app ChatOpeningBloc → chatPricingProvider (FutureProvider)
- Update router to use Riverpod-based auth state for redirects
- Update all auth screens (display name, register, OTP, force register)
- Update home screen and pricing bottom sheet
- Add android:usesCleartextTraffic for dev HTTP access on both apps
- mitra_app prepared with ProviderScope + ApiClient provider (blocs next)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 13:51:17 +08:00
parent b0502ac92b
commit d15b2f05fc
25 changed files with 2513 additions and 461 deletions

View File

@@ -1,15 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/auth/auth_bloc.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/auth/auth_notifier.dart';
class DisplayNameScreen extends StatefulWidget {
class DisplayNameScreen extends ConsumerStatefulWidget {
const DisplayNameScreen({super.key});
@override
State<DisplayNameScreen> createState() => _DisplayNameScreenState();
ConsumerState<DisplayNameScreen> createState() => _DisplayNameScreenState();
}
class _DisplayNameScreenState extends State<DisplayNameScreen> {
class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
final _controller = TextEditingController();
@override
@@ -21,46 +21,46 @@ class _DisplayNameScreenState extends State<DisplayNameScreen> {
void _submit() {
final name = _controller.text.trim();
if (name.isEmpty) return;
context.read<AuthBloc>().add(AnonymousLoginRequested(name));
ref.read(authProvider.notifier).loginAnonymous(name);
}
@override
Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
}
},
child: Scaffold(
appBar: AppBar(title: const Text('Siapa namamu?')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Pilih nama yang ingin kamu gunakan. Nama ini tidak akan terlihat oleh siapapun selain mitra kamu.'),
const SizedBox(height: 24),
TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'Nama panggilan',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.done,
onSubmitted: (_) => _submit(),
final authState = ref.watch(authProvider);
final isLoading = authState is AsyncLoading;
ref.listen(authProvider, (prev, next) {
if (next is AsyncError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
}
});
return Scaffold(
appBar: AppBar(title: const Text('Siapa namamu?')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Pilih nama yang ingin kamu gunakan. Nama ini tidak akan terlihat oleh siapapun selain mitra kamu.'),
const SizedBox(height: 24),
TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'Nama panggilan',
border: OutlineInputBorder(),
),
const SizedBox(height: 24),
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => ElevatedButton(
onPressed: state is AuthLoading ? null : _submit,
child: state is AuthLoading
? const CircularProgressIndicator()
: const Text('Lanjut'),
),
),
],
),
textInputAction: TextInputAction.done,
onSubmitted: (_) => _submit(),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: isLoading ? null : _submit,
child: isLoading
? const CircularProgressIndicator()
: const Text('Lanjut'),
),
],
),
),
);

View File

@@ -1,18 +1,18 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/auth/auth_bloc.dart';
import '../../../core/auth/auth_notifier.dart';
/// Shown when anonymity is disabled by admin.
/// User must link their account. Display name is pre-filled.
class ForceRegisterScreen extends StatefulWidget {
class ForceRegisterScreen extends ConsumerStatefulWidget {
const ForceRegisterScreen({super.key});
@override
State<ForceRegisterScreen> createState() => _ForceRegisterScreenState();
ConsumerState<ForceRegisterScreen> createState() => _ForceRegisterScreenState();
}
class _ForceRegisterScreenState extends State<ForceRegisterScreen> {
class _ForceRegisterScreenState extends ConsumerState<ForceRegisterScreen> {
final _phoneController = TextEditingController();
@override
@@ -23,80 +23,77 @@ class _ForceRegisterScreenState extends State<ForceRegisterScreen> {
@override
Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthOtpSent) {
context.push('/auth/otp', extra: _phoneController.text.trim());
}
if (state is AuthAuthenticated) {
// After linking, link account to existing anonymous record
context.read<AuthBloc>().add(LinkAccountRequested());
}
if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
}
},
child: Scaffold(
appBar: AppBar(title: const Text('Verifikasi Akun')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Untuk melanjutkan, kamu perlu mendaftarkan akun.',
style: TextStyle(fontSize: 16),
final authState = ref.watch(authProvider);
final isLoading = authState is AsyncLoading;
ref.listen(authProvider, (prev, next) {
final data = next.valueOrNull;
if (data is AuthOtpSentData) {
context.push('/auth/otp', extra: _phoneController.text.trim());
}
if (data is AuthAuthenticatedData) {
// After social login succeeds, link account to existing anonymous record
ref.read(authProvider.notifier).linkAccount();
}
if (next is AsyncError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
}
});
return Scaffold(
appBar: AppBar(title: const Text('Verifikasi Akun')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Untuk melanjutkan, kamu perlu mendaftarkan akun.',
style: TextStyle(fontSize: 16),
),
const SizedBox(height: 24),
ElevatedButton.icon(
icon: const Icon(Icons.g_mobiledata),
onPressed: isLoading ? null
: () => ref.read(authProvider.notifier).loginGoogle(),
label: const Text('Lanjut dengan Google'),
),
const SizedBox(height: 12),
ElevatedButton.icon(
icon: const Icon(Icons.apple),
onPressed: isLoading ? null
: () => ref.read(authProvider.notifier).loginApple(),
label: const Text('Lanjut dengan Apple'),
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Row(children: [
Expanded(child: Divider()),
Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')),
Expanded(child: Divider()),
]),
),
TextField(
controller: _phoneController,
decoration: const InputDecoration(
labelText: 'Nomor HP',
hintText: '+628xxxxxxxxxx',
border: OutlineInputBorder(),
),
const SizedBox(height: 24),
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => ElevatedButton.icon(
icon: const Icon(Icons.g_mobiledata),
onPressed: state is AuthLoading ? null
: () => context.read<AuthBloc>().add(GoogleLoginRequested()),
label: const Text('Lanjut dengan Google'),
),
),
const SizedBox(height: 12),
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => ElevatedButton.icon(
icon: const Icon(Icons.apple),
onPressed: state is AuthLoading ? null
: () => context.read<AuthBloc>().add(AppleLoginRequested()),
label: const Text('Lanjut dengan Apple'),
),
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Row(children: [
Expanded(child: Divider()),
Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')),
Expanded(child: Divider()),
]),
),
TextField(
controller: _phoneController,
decoration: const InputDecoration(
labelText: 'Nomor HP',
hintText: '+628xxxxxxxxxx',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 12),
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => ElevatedButton(
onPressed: state is AuthLoading ? null : () {
final phone = _phoneController.text.trim();
if (phone.isEmpty) return;
context.read<AuthBloc>().add(PhoneOtpRequested(phone));
},
child: state is AuthLoading
? const CircularProgressIndicator()
: const Text('Kirim OTP'),
),
),
],
),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: isLoading ? null : () {
final phone = _phoneController.text.trim();
if (phone.isEmpty) return;
ref.read(authProvider.notifier).requestOtp(phone);
},
child: isLoading
? const CircularProgressIndicator()
: const Text('Kirim OTP'),
),
],
),
),
);

View File

@@ -1,17 +1,28 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/auth/auth_bloc.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/auth/auth_notifier.dart';
class OtpScreen extends StatefulWidget {
class OtpScreen extends ConsumerStatefulWidget {
final String phone;
const OtpScreen({super.key, required this.phone});
@override
State<OtpScreen> createState() => _OtpScreenState();
ConsumerState<OtpScreen> createState() => _OtpScreenState();
}
class _OtpScreenState extends State<OtpScreen> {
class _OtpScreenState extends ConsumerState<OtpScreen> {
final _otpController = TextEditingController();
String? _verificationId;
@override
void initState() {
super.initState();
// Capture verification ID from current state
final data = ref.read(authProvider).valueOrNull;
if (data is AuthOtpSentData) {
_verificationId = data.verificationId;
}
}
@override
void dispose() {
@@ -21,46 +32,51 @@ class _OtpScreenState extends State<OtpScreen> {
@override
Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
}
},
child: Scaffold(
appBar: AppBar(title: const Text('Masukkan OTP')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Kode OTP telah dikirim ke ${widget.phone}'),
const SizedBox(height: 24),
TextField(
controller: _otpController,
decoration: const InputDecoration(
labelText: 'Kode OTP',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
maxLength: 6,
final authState = ref.watch(authProvider);
final isLoading = authState is AsyncLoading;
// Update verification ID if state changes
final data = authState.valueOrNull;
if (data is AuthOtpSentData) {
_verificationId = data.verificationId;
}
ref.listen(authProvider, (prev, next) {
if (next is AsyncError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
}
});
return Scaffold(
appBar: AppBar(title: const Text('Masukkan OTP')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Kode OTP telah dikirim ke ${widget.phone}'),
const SizedBox(height: 24),
TextField(
controller: _otpController,
decoration: const InputDecoration(
labelText: 'Kode OTP',
border: OutlineInputBorder(),
),
const SizedBox(height: 12),
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => ElevatedButton(
onPressed: state is AuthLoading ? null : () {
final otp = _otpController.text.trim();
if (otp.length != 6) return;
final verificationId = (state is AuthOtpSent) ? state.verificationId : '';
context.read<AuthBloc>().add(OtpVerified(verificationId, otp));
},
child: state is AuthLoading
? const CircularProgressIndicator()
: const Text('Verifikasi'),
),
),
],
),
keyboardType: TextInputType.number,
maxLength: 6,
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: isLoading ? null : () {
final otp = _otpController.text.trim();
if (otp.length != 6 || _verificationId == null) return;
ref.read(authProvider.notifier).verifyOtp(_verificationId!, otp);
},
child: isLoading
? const CircularProgressIndicator()
: const Text('Verifikasi'),
),
],
),
),
);

View File

@@ -1,16 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/auth/auth_bloc.dart';
import '../../../core/auth/auth_notifier.dart';
class RegisterScreen extends StatefulWidget {
class RegisterScreen extends ConsumerStatefulWidget {
const RegisterScreen({super.key});
@override
State<RegisterScreen> createState() => _RegisterScreenState();
ConsumerState<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends State<RegisterScreen> {
class _RegisterScreenState extends ConsumerState<RegisterScreen> {
final _phoneController = TextEditingController();
@override
@@ -21,71 +21,68 @@ class _RegisterScreenState extends State<RegisterScreen> {
@override
Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthOtpSent) {
context.push('/auth/otp', extra: _phoneController.text.trim());
}
if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
}
},
child: Scaffold(
appBar: AppBar(title: const Text('Masuk / Daftar')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => ElevatedButton.icon(
icon: const Icon(Icons.g_mobiledata),
onPressed: state is AuthLoading ? null
: () => context.read<AuthBloc>().add(GoogleLoginRequested()),
label: const Text('Lanjut dengan Google'),
),
final authState = ref.watch(authProvider);
final isLoading = authState is AsyncLoading;
ref.listen(authProvider, (prev, next) {
final data = next.valueOrNull;
if (data is AuthOtpSentData) {
context.push('/auth/otp', extra: _phoneController.text.trim());
}
if (next is AsyncError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
}
});
return Scaffold(
appBar: AppBar(title: const Text('Masuk / Daftar')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ElevatedButton.icon(
icon: const Icon(Icons.g_mobiledata),
onPressed: isLoading ? null
: () => ref.read(authProvider.notifier).loginGoogle(),
label: const Text('Lanjut dengan Google'),
),
const SizedBox(height: 12),
ElevatedButton.icon(
icon: const Icon(Icons.apple),
onPressed: isLoading ? null
: () => ref.read(authProvider.notifier).loginApple(),
label: const Text('Lanjut dengan Apple'),
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Row(children: [
Expanded(child: Divider()),
Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')),
Expanded(child: Divider()),
]),
),
TextField(
controller: _phoneController,
decoration: const InputDecoration(
labelText: 'Nomor HP',
hintText: '+628xxxxxxxxxx',
border: OutlineInputBorder(),
),
const SizedBox(height: 12),
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => ElevatedButton.icon(
icon: const Icon(Icons.apple),
onPressed: state is AuthLoading ? null
: () => context.read<AuthBloc>().add(AppleLoginRequested()),
label: const Text('Lanjut dengan Apple'),
),
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Row(children: [
Expanded(child: Divider()),
Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')),
Expanded(child: Divider()),
]),
),
TextField(
controller: _phoneController,
decoration: const InputDecoration(
labelText: 'Nomor HP',
hintText: '+628xxxxxxxxxx',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 12),
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => ElevatedButton(
onPressed: state is AuthLoading ? null : () {
final phone = _phoneController.text.trim();
if (phone.isEmpty) return;
context.read<AuthBloc>().add(PhoneOtpRequested(phone));
},
child: state is AuthLoading
? const CircularProgressIndicator()
: const Text('Kirim OTP'),
),
),
],
),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: isLoading ? null : () {
final phone = _phoneController.text.trim();
if (phone.isEmpty) return;
ref.read(authProvider.notifier).requestOtp(phone);
},
child: isLoading
? const CircularProgressIndicator()
: const Text('Kirim OTP'),
),
],
),
),
);

View File

@@ -1,11 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/api/api_client.dart';
import '../../../core/chat/chat_opening_bloc.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/chat/chat_opening_provider.dart';
import '../../../core/chat/session_closure_bloc.dart';
import '../../../core/pairing/pairing_bloc.dart';
class PricingBottomSheet extends StatelessWidget {
class PricingBottomSheet extends ConsumerWidget {
/// If set, the bottom sheet is in "extension" mode — selecting a tier extends the session.
final String? extensionSessionId;
@@ -16,14 +16,11 @@ class PricingBottomSheet extends StatelessWidget {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => BlocProvider(
create: (ctx) => ChatOpeningBloc(apiClient: ctx.read<ApiClient>())..add(LoadPricing()),
child: MultiBlocProvider(
providers: [
BlocProvider.value(value: context.read<PairingBloc>()),
],
child: const PricingBottomSheet(),
),
builder: (_) => MultiBlocProvider(
providers: [
BlocProvider.value(value: context.read<PairingBloc>()),
],
child: const PricingBottomSheet(),
),
);
}
@@ -33,14 +30,11 @@ class PricingBottomSheet extends StatelessWidget {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => BlocProvider(
create: (ctx) => ChatOpeningBloc(apiClient: ctx.read<ApiClient>())..add(LoadPricing()),
child: MultiBlocProvider(
providers: [
BlocProvider.value(value: context.read<SessionClosureBloc>()),
],
child: PricingBottomSheet(extensionSessionId: sessionId),
),
builder: (_) => MultiBlocProvider(
providers: [
BlocProvider.value(value: context.read<SessionClosureBloc>()),
],
child: PricingBottomSheet(extensionSessionId: sessionId),
),
);
}
@@ -56,94 +50,83 @@ class PricingBottomSheet extends StatelessWidget {
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final isExtension = extensionSessionId != null;
final pricingAsync = ref.watch(chatPricingProvider);
return BlocBuilder<ChatOpeningBloc, ChatOpeningState>(
builder: (context, state) {
if (state is PricingLoading || state is PricingInitial) {
return const SizedBox(
height: 200,
child: Center(child: CircularProgressIndicator()),
);
}
if (state is PricingError) {
return SizedBox(
height: 200,
child: Center(child: Text(state.message)),
);
}
if (state is PricingLoaded) {
return DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.4,
maxChildSize: 0.8,
expand: false,
builder: (_, scrollController) {
return Padding(
padding: const EdgeInsets.all(24),
child: ListView(
controller: scrollController,
children: [
Text(
isExtension ? 'Perpanjang Durasi' : 'Pilih Durasi Curhat',
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
if (!isExtension && state.freeTrialEligible) ...[
Card(
color: Colors.green.shade50,
child: ListTile(
leading: const Icon(Icons.card_giftcard, color: Colors.green),
title: Text('Free Trial (${state.freeTrialDurationMinutes} Menit)'),
subtitle: const Text('Gratis untuk pertama kali!'),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: () {
Navigator.of(context).pop();
_startPairing(context, isFreeTrial: true);
},
),
),
const Divider(height: 24),
],
...state.tiers.map((tier) => Card(
child: ListTile(
title: Text(tier.label),
trailing: Text(
_formatPrice(tier.price),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
onTap: () {
Navigator.of(context).pop();
if (isExtension) {
_requestExtension(
context,
sessionId: extensionSessionId!,
durationMinutes: tier.durationMinutes,
price: tier.price,
);
} else {
_startPairing(
context,
durationMinutes: tier.durationMinutes,
price: tier.price,
);
}
},
),
)),
],
return pricingAsync.when(
loading: () => const SizedBox(
height: 200,
child: Center(child: CircularProgressIndicator()),
),
error: (error, _) => SizedBox(
height: 200,
child: Center(child: Text('Gagal memuat harga. Coba lagi.')),
),
data: (pricing) => DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.4,
maxChildSize: 0.8,
expand: false,
builder: (_, scrollController) {
return Padding(
padding: const EdgeInsets.all(24),
child: ListView(
controller: scrollController,
children: [
Text(
isExtension ? 'Perpanjang Durasi' : 'Pilih Durasi Curhat',
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
);
},
const SizedBox(height: 16),
if (!isExtension && pricing.freeTrialEligible) ...[
Card(
color: Colors.green.shade50,
child: ListTile(
leading: const Icon(Icons.card_giftcard, color: Colors.green),
title: Text('Free Trial (${pricing.freeTrialDurationMinutes} Menit)'),
subtitle: const Text('Gratis untuk pertama kali!'),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: () {
Navigator.of(context).pop();
_startPairing(context, isFreeTrial: true);
},
),
),
const Divider(height: 24),
],
...pricing.tiers.map((tier) => Card(
child: ListTile(
title: Text(tier.label),
trailing: Text(
_formatPrice(tier.price),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
onTap: () {
Navigator.of(context).pop();
if (isExtension) {
_requestExtension(
context,
sessionId: extensionSessionId!,
durationMinutes: tier.durationMinutes,
price: tier.price,
);
} else {
_startPairing(
context,
durationMinutes: tier.durationMinutes,
price: tier.price,
);
}
},
),
)),
],
),
);
}
return const SizedBox.shrink();
},
},
),
);
}

View File

@@ -1,19 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/auth/auth_bloc.dart';
import '../../core/api/api_client.dart';
import '../../core/auth/auth_notifier.dart';
import '../../core/api/api_client_provider.dart';
import '../../core/pairing/pairing_bloc.dart';
import '../chat/widgets/pricing_bottom_sheet.dart';
class HomeScreen extends StatefulWidget {
class HomeScreen extends ConsumerStatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
ConsumerState<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObserver {
Map<String, dynamic>? _activeSession;
bool _loadingSession = true;
@@ -40,13 +41,12 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Re-check when navigating back to this screen
_checkActiveSession();
}
Future<void> _checkActiveSession() async {
try {
final apiClient = context.read<ApiClient>();
final apiClient = ref.read(apiClientProvider);
final response = await apiClient.get('/api/client/chat/session/active');
final data = response['data'];
if (mounted) {
@@ -62,6 +62,15 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final authData = authState.valueOrNull;
final displayName = switch (authData) {
AuthAuthenticatedData d => d.profile['display_name'] as String? ?? '',
AuthAnonymousData d => d.displayName,
_ => '',
};
return BlocListener<PairingBloc, PairingState>(
listener: (context, state) {
if (state is PairingSearching) {
@@ -74,63 +83,53 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
);
}
},
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
final displayName = state is AuthAuthenticated
? state.profile['display_name'] as String
: state is AuthAnonymous
? state.displayName
: '';
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: () => context.read<AuthBloc>().add(LogoutRequested()),
),
child: 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: 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),
if (_loadingSession)
const CircularProgressIndicator()
else if (_activeSession != null)
_ActiveSessionCard(
session: _activeSession!,
onTap: () {
final sessionId = _activeSession!['id'] as String;
final mitraName = _activeSession!['mitra_display_name'] as String? ?? 'Bestie';
context.push('/chat/session/$sessionId', extra: mitraName);
},
)
else ...[
const SizedBox(height: 16),
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16),
),
onPressed: () => PricingBottomSheet.show(context),
child: const Text('Mulai Curhat', style: TextStyle(fontSize: 18)),
),
],
],
),
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),
if (_loadingSession)
const CircularProgressIndicator()
else if (_activeSession != null)
_ActiveSessionCard(
session: _activeSession!,
onTap: () {
final sessionId = _activeSession!['id'] as String;
final mitraName = _activeSession!['mitra_display_name'] as String? ?? 'Bestie';
context.push('/chat/session/$sessionId', extra: mitraName);
},
)
else ...[
const SizedBox(height: 16),
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16),
),
onPressed: () => PricingBottomSheet.show(context),
child: const Text('Mulai Curhat', style: TextStyle(fontSize: 18)),
),
],
],
),
),
),
);
},
),
),
),
);
}