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

@@ -2,7 +2,8 @@
<application <application
android:label="client_app" android:label="client_app"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

View File

@@ -0,0 +1,8 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'api_client.dart';
part 'api_client_provider.g.dart';
@Riverpod(keepAlive: true)
ApiClient apiClient(Ref ref) => ApiClient();

View File

@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'api_client_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$apiClientHash() => r'90c807f03b90249684265cc91739139c2c89eeb9';
/// See also [apiClient].
@ProviderFor(apiClient)
final apiClientProvider = Provider<ApiClient>.internal(
apiClient,
name: r'apiClientProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$apiClientHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ApiClientRef = ProviderRef<ApiClient>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -0,0 +1,198 @@
import 'dart:async';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
import '../api/api_client.dart';
import '../api/api_client_provider.dart';
part 'auth_notifier.g.dart';
// States
sealed class AuthData {
const AuthData();
}
class AuthInitialData extends AuthData {
const AuthInitialData();
}
class AuthAuthenticatedData extends AuthData {
final Map<String, dynamic> profile;
const AuthAuthenticatedData(this.profile);
}
class AuthAnonymousData extends AuthData {
final String customerId;
final String displayName;
const AuthAnonymousData({required this.customerId, required this.displayName});
}
class AuthOtpSentData extends AuthData {
final String verificationId;
const AuthOtpSentData(this.verificationId);
}
class AuthForceRegisterData extends AuthData {
final String customerId;
final String displayName;
const AuthForceRegisterData({required this.customerId, required this.displayName});
}
@Riverpod(keepAlive: true)
class Auth extends _$Auth {
FirebaseAuth get _auth => FirebaseAuth.instance;
ApiClient get _apiClient => ref.read(apiClientProvider);
@override
FutureOr<AuthData> build() async {
final prefs = await SharedPreferences.getInstance();
final customerId = prefs.getString('anonymous_customer_id');
final displayName = prefs.getString('anonymous_display_name');
final currentUser = _auth.currentUser;
if (currentUser != null && currentUser.isAnonymous && customerId != null && displayName != null) {
try {
final config = await _apiClient.get('/api/shared/config/anonymity');
final anonymityEnabled = config['data']['anonymity_enabled'] as bool;
if (!anonymityEnabled) {
return AuthForceRegisterData(customerId: customerId, displayName: displayName);
}
return AuthAnonymousData(customerId: customerId, displayName: displayName);
} catch (_) {
return AuthAnonymousData(customerId: customerId, displayName: displayName);
}
} else if (currentUser != null && !currentUser.isAnonymous) {
return await _verifyAndReturn();
}
return const AuthInitialData();
}
Future<void> loginAnonymous(String displayName) async {
state = const AsyncLoading();
try {
await _auth.signInAnonymously();
final response = await _apiClient.post(
'/api/shared/customer/anonymous',
data: {'display_name': displayName},
);
final customer = response['data'] as Map<String, dynamic>;
final prefs = await SharedPreferences.getInstance();
await prefs.setString('anonymous_customer_id', customer['id'] as String);
await prefs.setString('anonymous_display_name', customer['display_name'] as String);
state = AsyncData(AuthAnonymousData(
customerId: customer['id'] as String,
displayName: customer['display_name'] as String,
));
} catch (e) {
state = AsyncError('Failed to continue as guest. Please try again.', StackTrace.current);
}
}
Future<void> loginGoogle() async {
state = const AsyncLoading();
try {
final googleUser = await GoogleSignIn().signIn();
if (googleUser == null) {
state = const AsyncData(AuthInitialData());
return;
}
final googleAuth = await googleUser.authentication;
final credential = GoogleAuthProvider.credential(
accessToken: googleAuth.accessToken,
idToken: googleAuth.idToken,
);
await _auth.signInWithCredential(credential);
state = AsyncData(await _verifyAndReturn());
} catch (e) {
state = AsyncError('Google sign-in failed. Please try again.', StackTrace.current);
}
}
Future<void> loginApple() async {
state = const AsyncLoading();
try {
final appleCredential = await SignInWithApple.getAppleIDCredential(
scopes: [AppleIDAuthorizationScopes.email],
);
final oauthCredential = OAuthProvider('apple.com').credential(
idToken: appleCredential.identityToken,
accessToken: appleCredential.authorizationCode,
);
await _auth.signInWithCredential(oauthCredential);
state = AsyncData(await _verifyAndReturn());
} catch (e) {
state = AsyncError('Apple sign-in failed. Please try again.', StackTrace.current);
}
}
Future<void> requestOtp(String phone) async {
state = const AsyncLoading();
final completer = Completer<void>();
await _auth.verifyPhoneNumber(
phoneNumber: phone,
verificationCompleted: (_) {
if (!completer.isCompleted) completer.complete();
},
verificationFailed: (e) {
state = AsyncError('Failed to send OTP. Please try again.', StackTrace.current);
if (!completer.isCompleted) completer.complete();
},
codeSent: (verificationId, _) {
state = AsyncData(AuthOtpSentData(verificationId));
if (!completer.isCompleted) completer.complete();
},
codeAutoRetrievalTimeout: (_) {
if (!completer.isCompleted) completer.complete();
},
);
await completer.future;
}
Future<void> verifyOtp(String verificationId, String smsCode) async {
state = const AsyncLoading();
try {
final credential = PhoneAuthProvider.credential(
verificationId: verificationId,
smsCode: smsCode,
);
await _auth.signInWithCredential(credential);
state = AsyncData(await _verifyAndReturn());
} catch (e) {
state = AsyncError('Invalid OTP. Please try again.', StackTrace.current);
}
}
Future<void> linkAccount() async {
final prefs = await SharedPreferences.getInstance();
final customerId = prefs.getString('anonymous_customer_id');
if (customerId == null || _auth.currentUser == null) return;
state = const AsyncLoading();
try {
await _apiClient.post('/api/shared/customer/link', data: {
'customer_id': customerId,
'firebase_uid': _auth.currentUser!.uid,
});
await prefs.remove('anonymous_customer_id');
await prefs.remove('anonymous_display_name');
state = AsyncData(await _verifyAndReturn());
} catch (e) {
state = AsyncError('Failed to link account. Please try again.', StackTrace.current);
}
}
Future<void> logout() async {
await _auth.signOut();
final prefs = await SharedPreferences.getInstance();
await prefs.remove('anonymous_customer_id');
await prefs.remove('anonymous_display_name');
state = const AsyncData(AuthInitialData());
}
Future<AuthData> _verifyAndReturn() async {
final response = await _apiClient.post('/api/client/auth/verify');
return AuthAuthenticatedData(response['data'] as Map<String, dynamic>);
}
}

View File

@@ -0,0 +1,24 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'auth_notifier.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$authHash() => r'8cb877e94ccf4366b574ffe8c8b4b63321340b6d';
/// See also [Auth].
@ProviderFor(Auth)
final authProvider = AsyncNotifierProvider<Auth, AuthData>.internal(
Auth.new,
name: r'authProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$authHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$Auth = AsyncNotifier<AuthData>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -0,0 +1,49 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../api/api_client_provider.dart';
part 'chat_opening_provider.g.dart';
class PriceTier {
final int durationMinutes;
final int price;
final String label;
PriceTier({required this.durationMinutes, required this.price, required this.label});
factory PriceTier.fromJson(Map<String, dynamic> json) {
return PriceTier(
durationMinutes: json['duration_minutes'] as int,
price: json['price'] as int,
label: json['label'] as String,
);
}
}
class PricingData {
final List<PriceTier> tiers;
final bool freeTrialEligible;
final int freeTrialDurationMinutes;
const PricingData({
required this.tiers,
required this.freeTrialEligible,
this.freeTrialDurationMinutes = 5,
});
}
@riverpod
Future<PricingData> chatPricing(Ref ref) async {
final apiClient = ref.read(apiClientProvider);
final response = await apiClient.get('/api/client/chat/pricing');
final data = response['data'] as Map<String, dynamic>;
final tiersJson = data['tiers'] as List<dynamic>;
final tiers = tiersJson.map((t) => PriceTier.fromJson(t as Map<String, dynamic>)).toList();
final freeTrial = data['free_trial'] as Map<String, dynamic>;
return PricingData(
tiers: tiers,
freeTrialEligible: freeTrial['eligible'] as bool? ?? false,
freeTrialDurationMinutes: freeTrial['duration_minutes'] as int? ?? 5,
);
}

View File

@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'chat_opening_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$chatPricingHash() => r'53ca829d46f7ba3b481e7a6b54482c62f9fe3ad0';
/// See also [chatPricing].
@ProviderFor(chatPricing)
final chatPricingProvider = AutoDisposeFutureProvider<PricingData>.internal(
chatPricing,
name: r'chatPricingProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$chatPricingHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ChatPricingRef = AutoDisposeFutureProviderRef<PricingData>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/api/api_client.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/chat/chat_opening_bloc.dart'; import '../../../core/chat/chat_opening_provider.dart';
import '../../../core/chat/session_closure_bloc.dart'; import '../../../core/chat/session_closure_bloc.dart';
import '../../../core/pairing/pairing_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. /// If set, the bottom sheet is in "extension" mode — selecting a tier extends the session.
final String? extensionSessionId; final String? extensionSessionId;
@@ -16,14 +16,11 @@ class PricingBottomSheet extends StatelessWidget {
return showModalBottomSheet( return showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: (_) => BlocProvider( builder: (_) => MultiBlocProvider(
create: (ctx) => ChatOpeningBloc(apiClient: ctx.read<ApiClient>())..add(LoadPricing()), providers: [
child: MultiBlocProvider( BlocProvider.value(value: context.read<PairingBloc>()),
providers: [ ],
BlocProvider.value(value: context.read<PairingBloc>()), child: const PricingBottomSheet(),
],
child: const PricingBottomSheet(),
),
), ),
); );
} }
@@ -33,14 +30,11 @@ class PricingBottomSheet extends StatelessWidget {
return showModalBottomSheet( return showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: (_) => BlocProvider( builder: (_) => MultiBlocProvider(
create: (ctx) => ChatOpeningBloc(apiClient: ctx.read<ApiClient>())..add(LoadPricing()), providers: [
child: MultiBlocProvider( BlocProvider.value(value: context.read<SessionClosureBloc>()),
providers: [ ],
BlocProvider.value(value: context.read<SessionClosureBloc>()), child: PricingBottomSheet(extensionSessionId: sessionId),
],
child: PricingBottomSheet(extensionSessionId: sessionId),
),
), ),
); );
} }
@@ -56,94 +50,83 @@ class PricingBottomSheet extends StatelessWidget {
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final isExtension = extensionSessionId != null; final isExtension = extensionSessionId != null;
final pricingAsync = ref.watch(chatPricingProvider);
return BlocBuilder<ChatOpeningBloc, ChatOpeningState>( return pricingAsync.when(
builder: (context, state) { loading: () => const SizedBox(
if (state is PricingLoading || state is PricingInitial) { height: 200,
return const SizedBox( child: Center(child: CircularProgressIndicator()),
height: 200, ),
child: Center(child: CircularProgressIndicator()), error: (error, _) => SizedBox(
); height: 200,
} child: Center(child: Text('Gagal memuat harga. Coba lagi.')),
),
if (state is PricingError) { data: (pricing) => DraggableScrollableSheet(
return SizedBox( initialChildSize: 0.6,
height: 200, minChildSize: 0.4,
child: Center(child: Text(state.message)), maxChildSize: 0.8,
); expand: false,
} builder: (_, scrollController) {
return Padding(
if (state is PricingLoaded) { padding: const EdgeInsets.all(24),
return DraggableScrollableSheet( child: ListView(
initialChildSize: 0.6, controller: scrollController,
minChildSize: 0.4, children: [
maxChildSize: 0.8, Text(
expand: false, isExtension ? 'Perpanjang Durasi' : 'Pilih Durasi Curhat',
builder: (_, scrollController) { style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
return Padding( textAlign: TextAlign.center,
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,
);
}
},
),
)),
],
), ),
); 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/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../core/auth/auth_bloc.dart'; import '../../core/auth/auth_notifier.dart';
import '../../core/api/api_client.dart'; import '../../core/api/api_client_provider.dart';
import '../../core/pairing/pairing_bloc.dart'; import '../../core/pairing/pairing_bloc.dart';
import '../chat/widgets/pricing_bottom_sheet.dart'; import '../chat/widgets/pricing_bottom_sheet.dart';
class HomeScreen extends StatefulWidget { class HomeScreen extends ConsumerStatefulWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
@override @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; Map<String, dynamic>? _activeSession;
bool _loadingSession = true; bool _loadingSession = true;
@@ -40,13 +41,12 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
// Re-check when navigating back to this screen
_checkActiveSession(); _checkActiveSession();
} }
Future<void> _checkActiveSession() async { Future<void> _checkActiveSession() async {
try { try {
final apiClient = context.read<ApiClient>(); final apiClient = ref.read(apiClientProvider);
final response = await apiClient.get('/api/client/chat/session/active'); final response = await apiClient.get('/api/client/chat/session/active');
final data = response['data']; final data = response['data'];
if (mounted) { if (mounted) {
@@ -62,6 +62,15 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
@override @override
Widget build(BuildContext context) { 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>( return BlocListener<PairingBloc, PairingState>(
listener: (context, state) { listener: (context, state) {
if (state is PairingSearching) { if (state is PairingSearching) {
@@ -74,63 +83,53 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
); );
} }
}, },
child: BlocBuilder<AuthBloc, AuthState>( child: Scaffold(
builder: (context, state) { appBar: AppBar(
final displayName = state is AuthAuthenticated title: const Text('Halo Bestie'),
? state.profile['display_name'] as String actions: [
: state is AuthAnonymous IconButton(
? state.displayName icon: const Icon(Icons.history),
: ''; onPressed: () => context.push('/chat/history'),
),
return Scaffold( IconButton(
appBar: AppBar( icon: const Icon(Icons.logout),
title: const Text('Halo Bestie'), onPressed: () => ref.read(authProvider.notifier).logout(),
actions: [ ),
IconButton( ],
icon: const Icon(Icons.history), ),
onPressed: () => context.push('/chat/history'), body: Center(
), child: Padding(
IconButton( padding: const EdgeInsets.all(32),
icon: const Icon(Icons.logout), child: Column(
onPressed: () => context.read<AuthBloc>().add(LogoutRequested()), 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)),
),
],
],
),
),
),
);
},
), ),
); );
} }

View File

@@ -2,9 +2,9 @@ import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/api/api_client.dart'; import 'core/api/api_client_provider.dart';
import 'core/auth/auth_bloc.dart'; import 'core/auth/auth_notifier.dart';
import 'core/chat/chat_bloc.dart'; import 'core/chat/chat_bloc.dart';
import 'core/chat/session_closure_bloc.dart'; import 'core/chat/session_closure_bloc.dart';
import 'core/pairing/pairing_bloc.dart'; import 'core/pairing/pairing_bloc.dart';
@@ -16,68 +16,64 @@ void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
// Request notification permission
final messaging = FirebaseMessaging.instance; final messaging = FirebaseMessaging.instance;
await messaging.requestPermission(); await messaging.requestPermission();
runApp(const App()); runApp(const ProviderScope(child: App()));
} }
class App extends StatefulWidget { class App extends ConsumerStatefulWidget {
const App({super.key}); const App({super.key});
@override @override
State<App> createState() => _AppState(); ConsumerState<App> createState() => _AppState();
} }
class _AppState extends State<App> { class _AppState extends ConsumerState<App> {
final _apiClient = ApiClient(); bool _fcmRegistered = false;
late final AuthBloc _authBloc;
late final GoRouter _router;
@override void _registerFcmToken() {
void initState() { if (_fcmRegistered) return;
super.initState(); _fcmRegistered = true;
_authBloc = AuthBloc(apiClient: _apiClient)..add(AppStarted()); Future(() async {
_router = buildRouter(_authBloc); try {
NotificationService.initialize(_router); final token = await FirebaseMessaging.instance.getToken();
_registerFcmToken(); if (token != null) {
} await ref.read(apiClientProvider).post('/api/shared/device-token', data: {'token': token});
}
Future<void> _registerFcmToken() async { } catch (_) {
// Listen for auth state, then register token _fcmRegistered = false;
_authBloc.stream.listen((state) async {
if (state is AuthAuthenticated || state is AuthAnonymous) {
try {
final token = await FirebaseMessaging.instance.getToken();
if (token != null) {
await _apiClient.post('/api/shared/device-token', data: {'token': token});
}
} catch (_) {}
} }
}); });
} }
@override
void dispose() {
_authBloc.close();
_router.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Listen for auth changes to register FCM token
ref.listen(authProvider, (prev, next) {
final data = next.valueOrNull;
if (data is AuthAuthenticatedData || data is AuthAnonymousData) {
_registerFcmToken();
}
});
final router = ref.watch(routerProvider);
final apiClient = ref.watch(apiClientProvider);
// Initialize notifications once router is available
NotificationService.initialize(router);
// Keep BlocProviders for non-migrated blocs (will be removed as they're migrated)
return MultiBlocProvider( return MultiBlocProvider(
providers: [ providers: [
BlocProvider.value(value: _authBloc), BlocProvider(create: (_) => PairingBloc(apiClient: apiClient)),
BlocProvider(create: (_) => PairingBloc(apiClient: _apiClient)), BlocProvider(create: (_) => ChatBloc(apiClient: apiClient)),
BlocProvider(create: (_) => ChatBloc(apiClient: _apiClient)), BlocProvider(create: (_) => SessionClosureBloc(apiClient: apiClient)),
BlocProvider(create: (_) => SessionClosureBloc(apiClient: _apiClient)), RepositoryProvider.value(value: apiClient),
RepositoryProvider.value(value: _apiClient),
], ],
child: MaterialApp.router( child: MaterialApp.router(
title: 'Halo Bestie', title: 'Halo Bestie',
routerConfig: _router, routerConfig: router,
), ),
); );
} }

View File

@@ -1,7 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'core/auth/auth_bloc.dart'; import 'core/auth/auth_notifier.dart';
import 'features/auth/screens/welcome_screen.dart'; import 'features/auth/screens/welcome_screen.dart';
import 'features/auth/screens/display_name_screen.dart'; import 'features/auth/screens/display_name_screen.dart';
import 'features/auth/screens/register_screen.dart'; import 'features/auth/screens/register_screen.dart';
@@ -16,38 +16,43 @@ import 'features/chat/screens/chat_screen.dart';
import 'features/chat/screens/chat_history_screen.dart'; import 'features/chat/screens/chat_history_screen.dart';
import 'features/chat/screens/chat_transcript_screen.dart'; import 'features/chat/screens/chat_transcript_screen.dart';
/// Converts a BLoC stream into a ChangeNotifier for GoRouter's refreshListenable. class RouterNotifier extends ChangeNotifier {
class _BlocRefreshNotifier extends ChangeNotifier { final Ref _ref;
late final StreamSubscription _subscription;
_BlocRefreshNotifier(AuthBloc bloc) { RouterNotifier(this._ref) {
_subscription = bloc.stream.listen((_) => notifyListeners()); _ref.listen(authProvider, (_, __) => notifyListeners());
}
@override
void dispose() {
_subscription.cancel();
super.dispose();
} }
} }
GoRouter buildRouter(AuthBloc authBloc) { final routerProvider = Provider<GoRouter>((ref) => buildRouter(ref));
GoRouter buildRouter(Ref ref) {
final notifier = RouterNotifier(ref);
return GoRouter( return GoRouter(
initialLocation: '/splash', initialLocation: '/splash',
refreshListenable: _BlocRefreshNotifier(authBloc), refreshListenable: notifier,
redirect: (context, state) { redirect: (context, state) {
final authState = authBloc.state; final authState = ref.read(authProvider);
final isSplash = state.matchedLocation == '/splash'; final isSplash = state.matchedLocation == '/splash';
final isAuthRoute = state.matchedLocation.startsWith('/auth') || final isAuthRoute = state.matchedLocation.startsWith('/auth') ||
state.matchedLocation == '/welcome'; state.matchedLocation == '/welcome';
// Show splash while loading // Show splash while loading
if (authState is AuthLoading) return isSplash ? null : '/splash'; if (authState is AsyncLoading) return isSplash ? null : '/splash';
if (authState is AuthAuthenticated || authState is AuthAnonymous) { final data = authState.valueOrNull;
if (data == null) {
// Error state — show login
if (!isAuthRoute && !isSplash) return '/welcome';
if (isSplash) return '/welcome';
return null;
}
if (data is AuthAuthenticatedData || data is AuthAnonymousData) {
return (isSplash || isAuthRoute) ? '/home' : null; return (isSplash || isAuthRoute) ? '/home' : null;
} }
if (authState is AuthForceRegister) return '/auth/force-register'; if (data is AuthForceRegisterData) return '/auth/force-register';
if (!isAuthRoute && !isSplash) return '/welcome'; if (!isAuthRoute && !isSplash) return '/welcome';
if (isSplash) return '/welcome'; if (isSplash) return '/welcome';
return null; return null;

View File

@@ -1,6 +1,14 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
url: "https://pub.dev"
source: hosted
version: "85.0.0"
_flutterfire_internals: _flutterfire_internals:
dependency: transitive dependency: transitive
description: description:
@@ -9,6 +17,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.35" version: "1.3.35"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c
url: "https://pub.dev"
source: hosted
version: "7.6.0"
analyzer_plugin:
dependency: transitive
description:
name: analyzer_plugin
sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce
url: "https://pub.dev"
source: hosted
version: "0.13.4"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -41,6 +65,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
build:
dependency: transitive
description:
name: build
sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0
url: "https://pub.dev"
source: hosted
version: "2.4.2"
build_config:
dependency: transitive
description:
name: build_config
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
build_daemon:
dependency: transitive
description:
name: build_daemon
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
url: "https://pub.dev"
source: hosted
version: "4.1.1"
build_resolvers:
dependency: transitive
description:
name: build_resolvers
sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0
url: "https://pub.dev"
source: hosted
version: "2.4.4"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: "74691599a5bc750dc96a6b4bfd48f7d9d66453eab04c7f4063134800d6a5c573"
url: "https://pub.dev"
source: hosted
version: "2.4.14"
build_runner_core:
dependency: transitive
description:
name: build_runner_core
sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021"
url: "https://pub.dev"
source: hosted
version: "8.0.0"
built_collection:
dependency: transitive
description:
name: built_collection
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
url: "https://pub.dev"
source: hosted
version: "5.1.1"
built_value:
dependency: transitive
description:
name: built_value
sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af"
url: "https://pub.dev"
source: hosted
version: "8.12.5"
characters: characters:
dependency: transitive dependency: transitive
description: description:
@@ -49,6 +137,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
ci:
dependency: transitive
description:
name: ci
sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13"
url: "https://pub.dev"
source: hosted
version: "0.1.0"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@@ -57,6 +169,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" version: "1.1.2"
code_builder:
dependency: transitive
description:
name: code_builder
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
url: "https://pub.dev"
source: hosted
version: "4.11.1"
collection: collection:
dependency: transitive dependency: transitive
description: description:
@@ -65,6 +185,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
crypto: crypto:
dependency: transitive dependency: transitive
description: description:
@@ -73,6 +201,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" version: "3.0.7"
custom_lint:
dependency: "direct dev"
description:
name: custom_lint
sha256: "9656925637516c5cf0f5da018b33df94025af2088fe09c8ae2ca54c53f2d9a84"
url: "https://pub.dev"
source: hosted
version: "0.7.6"
custom_lint_builder:
dependency: transitive
description:
name: custom_lint_builder
sha256: "6cdc8e87e51baaaba9c43e283ed8d28e59a0c4732279df62f66f7b5984655414"
url: "https://pub.dev"
source: hosted
version: "0.7.6"
custom_lint_core:
dependency: transitive
description:
name: custom_lint_core
sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be"
url: "https://pub.dev"
source: hosted
version: "0.7.5"
custom_lint_visitor:
dependency: transitive
description:
name: custom_lint_visitor
sha256: "4a86a0d8415a91fbb8298d6ef03e9034dc8e323a599ddc4120a0e36c433983a2"
url: "https://pub.dev"
source: hosted
version: "1.0.0+7.7.0"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
dbus: dbus:
dependency: transitive dependency: transitive
description: description:
@@ -201,6 +369,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.8.7" version: "3.8.7"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -214,6 +390,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.1.6" version: "8.1.6"
flutter_hooks:
dependency: "direct main"
description:
name: flutter_hooks
sha256: cde36b12f7188c85286fba9b38cc5a902e7279f36dd676967106c041dc9dde70
url: "https://pub.dev"
source: hosted
version: "0.20.5"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -254,6 +438,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" version: "3.0.0"
flutter_riverpod:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -264,6 +456,30 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
freezed_annotation:
dependency: transitive
description:
name: freezed_annotation
sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
frontend_server_client:
dependency: transitive
description:
name: frontend_server_client
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
url: "https://pub.dev"
source: hosted
version: "4.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
go_router: go_router:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -320,6 +536,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.4+4" version: "0.12.4+4"
graphs:
dependency: transitive
description:
name: graphs
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
hooks_riverpod:
dependency: "direct main"
description:
name: hooks_riverpod
sha256: "70bba33cfc5670c84b796e6929c54b8bc5be7d0fe15bb28c2560500b9ad06966"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
hotreloader:
dependency: transitive
description:
name: hotreloader
sha256: "66871df468fc24eee81f1a0a7cb98acc104716f9b7376d355437b48d633c4ebf"
url: "https://pub.dev"
source: hosted
version: "4.4.0"
http: http:
dependency: transitive dependency: transitive
description: description:
@@ -328,6 +568,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.6.0" version: "1.6.0"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser: http_parser:
dependency: transitive dependency: transitive
description: description:
@@ -336,6 +584,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
url: "https://pub.dev"
source: hosted
version: "4.11.0"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -416,6 +688,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@@ -472,6 +752,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
pool:
dependency: transitive
description:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
url: "https://pub.dev"
source: hosted
version: "1.5.2"
provider: provider:
dependency: transitive dependency: transitive
description: description:
@@ -480,6 +768,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.5+1" version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
riverpod:
dependency: transitive
description:
name: riverpod
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
riverpod_analyzer_utils:
dependency: transitive
description:
name: riverpod_analyzer_utils
sha256: "03a17170088c63aab6c54c44456f5ab78876a1ddb6032ffde1662ddab4959611"
url: "https://pub.dev"
source: hosted
version: "0.5.10"
riverpod_annotation:
dependency: "direct main"
description:
name: riverpod_annotation
sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8
url: "https://pub.dev"
source: hosted
version: "2.6.1"
riverpod_generator:
dependency: "direct dev"
description:
name: riverpod_generator
sha256: "44a0992d54473eb199ede00e2260bd3c262a86560e3c6f6374503d86d0580e36"
url: "https://pub.dev"
source: hosted
version: "2.6.5"
riverpod_lint:
dependency: "direct dev"
description:
name: riverpod_lint
sha256: "89a52b7334210dbff8605c3edf26cfe69b15062beed5cbfeff2c3812c33c9e35"
url: "https://pub.dev"
source: hosted
version: "2.6.5"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
shared_preferences: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -536,6 +888,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.1" version: "2.4.1"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67
url: "https://pub.dev"
source: hosted
version: "2.0.1"
sign_in_with_apple: sign_in_with_apple:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -565,6 +933,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
source_gen:
dependency: transitive
description:
name: source_gen
sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
source_span: source_span:
dependency: transitive dependency: transitive
description: description:
@@ -581,6 +957,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.12.1" version: "1.12.1"
state_notifier:
dependency: transitive
description:
name: state_notifier
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
url: "https://pub.dev"
source: hosted
version: "1.0.0"
stream_channel: stream_channel:
dependency: transitive dependency: transitive
description: description:
@@ -589,6 +973,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.dev"
source: hosted
version: "2.1.1"
string_scanner: string_scanner:
dependency: transitive dependency: transitive
description: description:
@@ -621,6 +1013,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.11.0" version: "0.11.0"
timing:
dependency: transitive
description:
name: timing
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -629,6 +1029,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -645,6 +1053,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.0.2" version: "15.0.2"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web: web:
dependency: transitive dependency: transitive
description: description:
@@ -677,6 +1093,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.6.1" version: "6.6.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks: sdks:
dart: ">=3.10.0 <4.0.0" dart: ">=3.10.0 <4.0.0"
flutter: ">=3.38.1" flutter: ">=3.38.1"

View File

@@ -27,6 +27,10 @@ dependencies:
# State management # State management
flutter_bloc: ^8.1.5 flutter_bloc: ^8.1.5
equatable: ^2.0.5 equatable: ^2.0.5
flutter_riverpod: ^2.6.1
hooks_riverpod: ^2.6.1
riverpod_annotation: ^2.6.1
flutter_hooks: ^0.20.5
# Storage # Storage
shared_preferences: ^2.2.3 shared_preferences: ^2.2.3
@@ -39,6 +43,10 @@ dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^3.0.0 flutter_lints: ^3.0.0
riverpod_generator: ^2.6.2
build_runner: ^2.4.13
custom_lint: ^0.7.0
riverpod_lint: ^2.6.2
flutter: flutter:
uses-material-design: true uses-material-design: true

View File

@@ -2,7 +2,8 @@
<application <application
android:label="mitra_app" android:label="mitra_app"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

View File

@@ -0,0 +1,8 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'api_client.dart';
part 'api_client_provider.g.dart';
@Riverpod(keepAlive: true)
ApiClient apiClient(Ref ref) => ApiClient();

View File

@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'api_client_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$apiClientHash() => r'90c807f03b90249684265cc91739139c2c89eeb9';
/// See also [apiClient].
@ProviderFor(apiClient)
final apiClientProvider = Provider<ApiClient>.internal(
apiClient,
name: r'apiClientProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$apiClientHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ApiClientRef = ProviderRef<ApiClient>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -2,6 +2,7 @@ import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'core/api/api_client.dart'; import 'core/api/api_client.dart';
import 'core/auth/auth_bloc.dart'; import 'core/auth/auth_bloc.dart';
@@ -20,7 +21,7 @@ void main() async {
final messaging = FirebaseMessaging.instance; final messaging = FirebaseMessaging.instance;
await messaging.requestPermission(); await messaging.requestPermission();
runApp(const App()); runApp(const ProviderScope(child: App()));
} }
class App extends StatefulWidget { class App extends StatefulWidget {

View File

@@ -1,6 +1,14 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
url: "https://pub.dev"
source: hosted
version: "85.0.0"
_flutterfire_internals: _flutterfire_internals:
dependency: transitive dependency: transitive
description: description:
@@ -9,6 +17,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.35" version: "1.3.35"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c
url: "https://pub.dev"
source: hosted
version: "7.6.0"
analyzer_plugin:
dependency: transitive
description:
name: analyzer_plugin
sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce
url: "https://pub.dev"
source: hosted
version: "0.13.4"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -41,6 +65,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
build:
dependency: transitive
description:
name: build
sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0
url: "https://pub.dev"
source: hosted
version: "2.4.2"
build_config:
dependency: transitive
description:
name: build_config
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
build_daemon:
dependency: transitive
description:
name: build_daemon
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
url: "https://pub.dev"
source: hosted
version: "4.1.1"
build_resolvers:
dependency: transitive
description:
name: build_resolvers
sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0
url: "https://pub.dev"
source: hosted
version: "2.4.4"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: "74691599a5bc750dc96a6b4bfd48f7d9d66453eab04c7f4063134800d6a5c573"
url: "https://pub.dev"
source: hosted
version: "2.4.14"
build_runner_core:
dependency: transitive
description:
name: build_runner_core
sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021"
url: "https://pub.dev"
source: hosted
version: "8.0.0"
built_collection:
dependency: transitive
description:
name: built_collection
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
url: "https://pub.dev"
source: hosted
version: "5.1.1"
built_value:
dependency: transitive
description:
name: built_value
sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af"
url: "https://pub.dev"
source: hosted
version: "8.12.5"
characters: characters:
dependency: transitive dependency: transitive
description: description:
@@ -49,6 +137,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
ci:
dependency: transitive
description:
name: ci
sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13"
url: "https://pub.dev"
source: hosted
version: "0.1.0"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@@ -57,6 +169,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" version: "1.1.2"
code_builder:
dependency: transitive
description:
name: code_builder
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
url: "https://pub.dev"
source: hosted
version: "4.11.1"
collection: collection:
dependency: transitive dependency: transitive
description: description:
@@ -65,6 +185,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
crypto: crypto:
dependency: transitive dependency: transitive
description: description:
@@ -73,6 +201,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" version: "3.0.7"
custom_lint:
dependency: "direct dev"
description:
name: custom_lint
sha256: "9656925637516c5cf0f5da018b33df94025af2088fe09c8ae2ca54c53f2d9a84"
url: "https://pub.dev"
source: hosted
version: "0.7.6"
custom_lint_builder:
dependency: transitive
description:
name: custom_lint_builder
sha256: "6cdc8e87e51baaaba9c43e283ed8d28e59a0c4732279df62f66f7b5984655414"
url: "https://pub.dev"
source: hosted
version: "0.7.6"
custom_lint_core:
dependency: transitive
description:
name: custom_lint_core
sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be"
url: "https://pub.dev"
source: hosted
version: "0.7.5"
custom_lint_visitor:
dependency: transitive
description:
name: custom_lint_visitor
sha256: "4a86a0d8415a91fbb8298d6ef03e9034dc8e323a599ddc4120a0e36c433983a2"
url: "https://pub.dev"
source: hosted
version: "1.0.0+7.7.0"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
dbus: dbus:
dependency: transitive dependency: transitive
description: description:
@@ -121,6 +289,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
firebase_auth: firebase_auth:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -193,6 +369,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.8.7" version: "3.8.7"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -206,6 +390,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.1.6" version: "8.1.6"
flutter_hooks:
dependency: "direct main"
description:
name: flutter_hooks
sha256: cde36b12f7188c85286fba9b38cc5a902e7279f36dd676967106c041dc9dde70
url: "https://pub.dev"
source: hosted
version: "0.20.5"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -246,6 +438,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" version: "3.0.0"
flutter_riverpod:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -256,6 +456,30 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
freezed_annotation:
dependency: transitive
description:
name: freezed_annotation
sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
frontend_server_client:
dependency: transitive
description:
name: frontend_server_client
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
url: "https://pub.dev"
source: hosted
version: "4.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
go_router: go_router:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -264,6 +488,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "13.2.5" version: "13.2.5"
graphs:
dependency: transitive
description:
name: graphs
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
hooks_riverpod:
dependency: "direct main"
description:
name: hooks_riverpod
sha256: "70bba33cfc5670c84b796e6929c54b8bc5be7d0fe15bb28c2560500b9ad06966"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
hotreloader:
dependency: transitive
description:
name: hotreloader
sha256: "66871df468fc24eee81f1a0a7cb98acc104716f9b7376d355437b48d633c4ebf"
url: "https://pub.dev"
source: hosted
version: "4.4.0"
http: http:
dependency: transitive dependency: transitive
description: description:
@@ -272,6 +520,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.6.0" version: "1.6.0"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser: http_parser:
dependency: transitive dependency: transitive
description: description:
@@ -280,6 +536,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
url: "https://pub.dev"
source: hosted
version: "4.11.0"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -360,6 +640,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@@ -384,6 +672,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
pool:
dependency: transitive
description:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
url: "https://pub.dev"
source: hosted
version: "1.5.2"
provider: provider:
dependency: transitive dependency: transitive
description: description:
@@ -392,11 +688,99 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.5+1" version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
riverpod:
dependency: transitive
description:
name: riverpod
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
riverpod_analyzer_utils:
dependency: transitive
description:
name: riverpod_analyzer_utils
sha256: "03a17170088c63aab6c54c44456f5ab78876a1ddb6032ffde1662ddab4959611"
url: "https://pub.dev"
source: hosted
version: "0.5.10"
riverpod_annotation:
dependency: "direct main"
description:
name: riverpod_annotation
sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8
url: "https://pub.dev"
source: hosted
version: "2.6.1"
riverpod_generator:
dependency: "direct dev"
description:
name: riverpod_generator
sha256: "44a0992d54473eb199ede00e2260bd3c262a86560e3c6f6374503d86d0580e36"
url: "https://pub.dev"
source: hosted
version: "2.6.5"
riverpod_lint:
dependency: "direct dev"
description:
name: riverpod_lint
sha256: "89a52b7334210dbff8605c3edf26cfe69b15062beed5cbfeff2c3812c33c9e35"
url: "https://pub.dev"
source: hosted
version: "2.6.5"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67
url: "https://pub.dev"
source: hosted
version: "2.0.1"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
source_gen:
dependency: transitive
description:
name: source_gen
sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
source_span: source_span:
dependency: transitive dependency: transitive
description: description:
@@ -413,6 +797,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.12.1" version: "1.12.1"
state_notifier:
dependency: transitive
description:
name: state_notifier
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
url: "https://pub.dev"
source: hosted
version: "1.0.0"
stream_channel: stream_channel:
dependency: transitive dependency: transitive
description: description:
@@ -421,6 +813,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.dev"
source: hosted
version: "2.1.1"
string_scanner: string_scanner:
dependency: transitive dependency: transitive
description: description:
@@ -453,6 +853,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.11.0" version: "0.11.0"
timing:
dependency: transitive
description:
name: timing
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -461,6 +869,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -477,6 +893,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.0.2" version: "15.0.2"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web: web:
dependency: transitive dependency: transitive
description: description:
@@ -509,6 +933,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.6.1" version: "6.6.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks: sdks:
dart: ">=3.10.0 <4.0.0" dart: ">=3.10.0 <4.0.0"
flutter: ">=3.38.1" flutter: ">=3.38.1"

View File

@@ -23,6 +23,10 @@ dependencies:
# State management # State management
flutter_bloc: ^8.1.5 flutter_bloc: ^8.1.5
equatable: ^2.0.5 equatable: ^2.0.5
flutter_riverpod: ^2.6.1
hooks_riverpod: ^2.6.1
riverpod_annotation: ^2.6.1
flutter_hooks: ^0.20.5
# Navigation # Navigation
go_router: ^13.2.1 go_router: ^13.2.1
@@ -32,6 +36,10 @@ dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^3.0.0 flutter_lints: ^3.0.0
riverpod_generator: ^2.6.2
build_runner: ^2.4.13
custom_lint: ^0.7.0
riverpod_lint: ^2.6.2
flutter: flutter:
uses-material-design: true uses-material-design: true

View File

@@ -0,0 +1,735 @@
# Phase 3.1 Implementation Plan: Riverpod Migration & FCM Fallback
## Summary of Clarified Requirements
| Topic | Decision |
|---|---|
| Work stream order | Riverpod migration first, then FCM fallback |
| Riverpod style | Annotation-based (`@riverpod`) with code generation |
| Mitra AuthBloc bug | Fix stuck-loading: emit `AuthInitial` when `currentUser` is null |
| Mitra ping config | Control Center toggle: "require mitra ping" (boolean) + ping interval (seconds) |
| Non-ping mode | Mitra stays online without heartbeat; no auto-offline timeout; QC handles quality |
| Pairing FCM fallback | When WebSocket to mitra is closed, send pairing request via FCM push |
| Mitra pairing confirmation | Must manually accept (no auto-accept via FCM) |
| Unread badges (mitra) | Badge on "active sessions" button on home; badge on each session in list |
| Unread badges (customer) | Badge on `_ActiveSessionCard` widget on home screen |
| Badge clearing | Badges clear when messages are read |
| Closure FCM fallback | Backend sends closure signal to both parties; uses FCM if WebSocket is down |
| Closure screen | Must show closure screen on app (no silent updates) |
| Control center | New config: "require mitra ping" toggle + ping interval input |
| Backend changes for Riverpod | None — migration is Flutter-only |
---
## Work Stream 1: Riverpod Migration (Flutter-only)
### 1.1 Dependency Changes
#### Both `client_app/pubspec.yaml` and `mitra_app/pubspec.yaml`
**Add to dependencies:**
| Package | Purpose |
|---|---|
| `flutter_riverpod` | Core Riverpod provider framework |
| `hooks_riverpod` | Riverpod + flutter_hooks integration (`HookConsumerWidget`) |
| `riverpod_annotation` | `@riverpod` / `@Riverpod(keepAlive: true)` annotations |
| `flutter_hooks` | Hook utilities (`useTextEditingController`, `useEffect`, etc.) |
**Add to dev_dependencies:**
| Package | Purpose |
|---|---|
| `riverpod_generator` | Code generation for `@riverpod` providers |
| `build_runner` | Runs code generation (`dart run build_runner build`) |
| `custom_lint` | Required for riverpod_lint rules |
| `riverpod_lint` | Lint rules for Riverpod best practices |
**Remove after all Blocs are migrated:**
| Package | |
|---|---|
| `flutter_bloc` | Replaced by Riverpod |
| `equatable` | No longer needed — Riverpod state is compared by value |
### 1.2 App Root Changes
#### `client_app/lib/main.dart`
**Current:** `MultiBlocProvider` wraps `MaterialApp.router` with `AuthBloc`, `PairingBloc`, `ChatBloc`, `SessionClosureBloc`.
**Target:**
1. Wrap `runApp` call with `ProviderScope`: `runApp(const ProviderScope(child: App()))`
2. Convert `App` from `StatefulWidget` to `HookConsumerWidget`
3. Remove `MultiBlocProvider` wrapper — providers are globally available via `ref`
4. Replace `_authBloc.stream.listen(...)` for FCM token registration with `ref.listen(authProvider, ...)`
5. Move `ApiClient` into a Riverpod provider: `@Riverpod(keepAlive: true) ApiClient apiClient(Ref ref) => ApiClient()`
6. Router creation: Use `ref.watch(authProvider)` to get auth state for redirect logic; replace `_BlocRefreshNotifier` with a Riverpod-based `ChangeNotifier` or use `ref.listen` on the auth provider
**Files changed:**
- `client_app/lib/main.dart`
- `client_app/lib/router.dart` (remove `_BlocRefreshNotifier`, accept `WidgetRef` or use a provider for router)
#### `mitra_app/lib/main.dart`
**Current:** `MultiBlocProvider` wraps app with `AuthBloc`, `StatusBloc`, `ChatRequestBloc`, `MitraChatBloc`, `ExtensionBloc`. Also has `WidgetsBindingObserver` for lifecycle and `BlocListener<AuthBloc>` to trigger `StatusLoadRequested`.
**Target:**
1. Wrap with `ProviderScope`
2. Convert `App` to `HookConsumerWidget`
3. Remove `MultiBlocProvider` — use `ref.watch()` / `ref.listen()` instead
4. Move lifecycle observer to a dedicated provider or custom hook (`useAppLifecycleState`)
5. Replace `BlocListener<AuthBloc>` triggering status load with `ref.listen(authProvider, ...)` inside a provider or widget
**Files changed:**
- `mitra_app/lib/main.dart`
- `mitra_app/lib/router.dart`
### 1.3 Migration Per Bloc — Client App
Migration order: AuthBloc (simplest, foundational) → ChatOpeningBloc (simple, no side effects) → SessionClosureBloc (simple API calls) → PairingBloc (WebSocket + timers) → ChatBloc (most complex, WebSocket + message state).
#### 1.3.1 `client_app` AuthBloc → AuthNotifier
**Source file:** `client_app/lib/core/auth/auth_bloc.dart`
**Current:** BLoC with 8 events (AppStarted, AnonymousLoginRequested, GoogleLoginRequested, AppleLoginRequested, PhoneOtpRequested, OtpVerified, LinkAccountRequested, LogoutRequested) and 7 state classes.
**Target:** `@Riverpod(keepAlive: true)` AsyncNotifier.
```dart
@Riverpod(keepAlive: true)
class Auth extends _$Auth {
// State type: AsyncValue<AuthData> (sealed class)
// Build method: checks Firebase currentUser, calls _verifyAndReturn or returns AuthData.initial
// Methods: loginAnonymous(displayName), loginGoogle(), loginApple(),
// requestOtp(phone), verifyOtp(verificationId, smsCode),
// linkAccount(), logout()
}
```
**State design:** Replace 7 separate state classes with a single sealed class:
```dart
sealed class AuthData {
const AuthData();
}
class AuthDataInitial extends AuthData { const AuthDataInitial(); }
class AuthDataAuthenticated extends AuthData { final Map<String, dynamic> profile; ... }
class AuthDataAnonymous extends AuthData { final String customerId; final String displayName; ... }
class AuthDataOtpSent extends AuthData { final String verificationId; ... }
class AuthDataForceRegister extends AuthData { final String customerId; final String displayName; ... }
```
The `AsyncValue` wrapper handles loading/error automatically:
- `state = const AsyncLoading()` replaces `emit(AuthLoading())`
- `state = AsyncData(AuthDataAuthenticated(...))` replaces `emit(AuthAuthenticated(...))`
- `state = AsyncError(...)` replaces `emit(AuthError(...))`
**Widget changes:**
- `BlocBuilder<AuthBloc, AuthState>``ConsumerWidget` + `ref.watch(authProvider)`
- `BlocListener<AuthBloc, AuthState>``ref.listen(authProvider, ...)`
- `context.read<AuthBloc>().add(LogoutRequested())``ref.read(authProvider.notifier).logout()`
**Files affected:**
- `client_app/lib/core/auth/auth_bloc.dart``auth_notifier.dart` + `.g.dart`
- `client_app/lib/features/auth/screens/welcome_screen.dart`
- `client_app/lib/features/auth/screens/display_name_screen.dart`
- `client_app/lib/features/auth/screens/register_screen.dart`
- `client_app/lib/features/auth/screens/otp_screen.dart`
- `client_app/lib/features/auth/screens/force_register_screen.dart`
- `client_app/lib/features/home/home_screen.dart`
- `client_app/lib/router.dart`
- `client_app/lib/main.dart`
#### 1.3.2 `client_app` ChatOpeningBloc → ChatOpeningNotifier
**Source file:** `client_app/lib/core/chat/chat_opening_bloc.dart`
**Current:** Simple BLoC with one event (`LoadPricing`) and 4 states. Fetches pricing tiers from API.
**Target:** `@riverpod` FutureProvider (auto-dispose, since pricing is ephemeral):
```dart
@riverpod
Future<PricingData> chatPricing(Ref ref) async {
final apiClient = ref.read(apiClientProvider);
final response = await apiClient.get('/api/client/chat/pricing');
// parse and return PricingData
}
```
**PriceTier model** stays the same; move out of bloc file into a shared models file.
**Files affected:**
- `client_app/lib/core/chat/chat_opening_bloc.dart``chat_opening_notifier.dart` + `.g.dart`
- `client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart`
#### 1.3.3 `client_app` SessionClosureBloc → SessionClosureNotifier
**Source file:** `client_app/lib/core/chat/session_closure_bloc.dart`
**Current:** BLoC with 4 events (RequestExtension, DeclineExtension, ResetClosure, SubmitGoodbye) and 6 states.
**Target:** `@riverpod` Notifier (synchronous state, async methods).
```dart
@riverpod
class SessionClosure extends _$SessionClosure {
// State: SessionClosureData sealed class
// (Initial, ExtendingWaitingMitra, ShowGoodbye, Submitting, Complete, Error)
// build(): returns SessionClosureData.initial
// requestExtension(sessionId, durationMinutes, price)
// declineExtension()
// reset()
// submitGoodbye(sessionId, message)
}
```
**Files affected:**
- `client_app/lib/core/chat/session_closure_bloc.dart``session_closure_notifier.dart` + `.g.dart`
- `client_app/lib/features/chat/screens/chat_screen.dart`
#### 1.3.4 `client_app` PairingBloc → PairingNotifier
**Source file:** `client_app/lib/core/pairing/pairing_bloc.dart`
**Current:** BLoC with WebSocket connection, 60s timeout timer, pairing request flow. 6 public events + 3 private events, 7 state classes.
**Target:** `@Riverpod(keepAlive: true)` Notifier with internal WebSocket and timer management.
```dart
@Riverpod(keepAlive: true)
class Pairing extends _$Pairing {
WebSocketChannel? _channel;
Timer? _timeoutTimer;
StreamSubscription? _wsSubscription;
// State: PairingData sealed class
// (Initial, Searching, BestieFound, Active, NoBestie, Cancelled, Error)
// build(): returns PairingData.initial
// requestPairing()
// requestPairingWithTier({durationMinutes, price, isFreeTrial})
// cancelPairing()
// Internal: _connectWebSocket(), _onStatusUpdate(), _cleanup()
}
```
**Key difference from BLoC:** Private events (`_PairingStatusUpdate`, `_PairingTimeout`, `_ConnectionError`) become direct method calls within the notifier since Riverpod notifiers can call `state = ...` from callbacks without needing to route through an event system.
**Files affected:**
- `client_app/lib/core/pairing/pairing_bloc.dart``pairing_notifier.dart` + `.g.dart`
- `client_app/lib/features/home/home_screen.dart`
- `client_app/lib/features/chat/screens/searching_screen.dart`
- `client_app/lib/features/chat/screens/no_bestie_screen.dart`
- `client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart`
#### 1.3.5 `client_app` ChatBloc → ChatNotifier
**Source file:** `client_app/lib/core/chat/chat_bloc.dart`
**Current:** Most complex BLoC. Manages WebSocket connection, message list, typing indicators, session timer, message delivery/read status. 8 events, 4 state classes. `ChatConnected` has `copyWith` for granular updates.
**Target:** `@riverpod` Notifier with internal WebSocket management.
```dart
@riverpod
class Chat extends _$Chat {
WebSocketChannel? _channel;
StreamSubscription? _wsSubscription;
Timer? _typingTimer;
// State: ChatData sealed class
// (Initial, Connecting, Connected, Error)
// ChatDataConnected holds: messages, isOtherTyping, remainingSeconds,
// sessionExpired, sessionPaused, sessionClosing, extensionResponse
// build(): returns ChatData.initial
// connect(sessionId), disconnect()
// sendMessage(content), sendTyping()
// markDelivered(messageIds), markRead(messageIds)
}
```
**Files affected:**
- `client_app/lib/core/chat/chat_bloc.dart``chat_notifier.dart` + `.g.dart`
- `client_app/lib/features/chat/screens/chat_screen.dart`
- `client_app/lib/features/chat/screens/bestie_found_screen.dart`
### 1.4 Migration Per Bloc — Mitra App
Migration order: AuthBloc (fix bug here) → StatusBloc (timer management) → ExtensionBloc (simple) → ChatRequestBloc (WebSocket) → MitraChatBloc (most complex).
#### 1.4.1 `mitra_app` AuthBloc → AuthNotifier (BUG FIX)
**Source file:** `mitra_app/lib/core/auth/auth_bloc.dart`
**Bug:** Lines 65-68 — `_onAppStarted` only calls `_verifyAndEmit` when `_auth.currentUser != null`, but does NOT emit `AuthInitial` when `currentUser` is null. This leaves the app stuck in `AuthLoading`. The client_app's version correctly has `else { emit(AuthInitial()); }`.
**Fix during migration:** The `build()` method of the new AsyncNotifier must return `AuthDataInitial` when `currentUser` is null.
**Target:** `@Riverpod(keepAlive: true)` AsyncNotifier.
```dart
@Riverpod(keepAlive: true)
class Auth extends _$Auth {
ConfirmationResult? _webConfirmationResult;
@override
FutureOr<MitraAuthData> build() async {
final currentUser = FirebaseAuth.instance.currentUser;
if (currentUser != null) {
return await _verifyAndReturn(); // returns MitraAuthData.authenticated(profile)
}
return const MitraAuthData.initial(); // FIX: explicitly return initial state
}
// Methods: requestOtp(phone), verifyOtp(verificationId, smsCode), logout()
}
```
**Files affected:**
- `mitra_app/lib/core/auth/auth_bloc.dart``auth_notifier.dart` + `.g.dart`
- `mitra_app/lib/features/auth/screens/login_screen.dart`
- `mitra_app/lib/features/auth/screens/otp_screen.dart`
- `mitra_app/lib/features/home/home_screen.dart`
- `mitra_app/lib/router.dart`
- `mitra_app/lib/main.dart`
#### 1.4.2 `mitra_app` StatusBloc → StatusNotifier
**Source file:** `mitra_app/lib/core/status/status_bloc.dart`
**Current:** BLoC with 6 events, heartbeat timer management, 4 states.
**Target:** `@Riverpod(keepAlive: true)` Notifier (keepAlive because status persists across screens).
```dart
@Riverpod(keepAlive: true)
class OnlineStatus extends _$OnlineStatus {
Timer? _heartbeatTimer;
// State: OnlineStatusData (Initial, Loaded{isOnline}, Loading, Error)
// build(): returns OnlineStatusData.initial
// load(), toggleOnline(), toggleOffline(), onAppPaused(), onAppResumed()
// Private: _startHeartbeat(), _stopHeartbeat(), _heartbeatTick()
}
```
**Files affected:**
- `mitra_app/lib/core/status/status_bloc.dart``online_status_notifier.dart` + `.g.dart`
- `mitra_app/lib/features/home/home_screen.dart`
- `mitra_app/lib/main.dart` (lifecycle handling)
#### 1.4.3 `mitra_app` ExtensionBloc → ExtensionNotifier
**Source file:** `mitra_app/lib/core/chat/extension_bloc.dart`
**Current:** Simple BLoC with 2 events, 6 states.
**Target:** `@riverpod` Notifier.
```dart
@riverpod
class Extension extends _$Extension {
// State: ExtensionData (Idle, Responding, ShowGoodbye, Submitting, Complete, Error)
// build(): returns ExtensionData.idle
// respond(sessionId, extensionId, accepted)
// submitGoodbye(sessionId, message)
}
```
**Files affected:**
- `mitra_app/lib/core/chat/extension_bloc.dart``extension_notifier.dart` + `.g.dart`
- `mitra_app/lib/features/chat/screens/mitra_chat_screen.dart`
#### 1.4.4 `mitra_app` ChatRequestBloc → ChatRequestNotifier
**Source file:** `mitra_app/lib/core/chat/chat_request_bloc.dart`
**Current:** BLoC with WebSocket connection for incoming chat requests. 6 events, 6 states.
**Target:** `@Riverpod(keepAlive: true)` Notifier.
```dart
@Riverpod(keepAlive: true)
class ChatRequest extends _$ChatRequest {
WebSocketChannel? _channel;
StreamSubscription? _wsSubscription;
// State: ChatRequestData
// (Idle, Listening, Incoming{sessionId}, Accepting, Accepted{session}, Error)
// build(): returns ChatRequestData.idle
// startListening(), stopListening(), accept(sessionId), decline(sessionId)
// Private: _connectWebSocket(), _onRequestReceived(), _closeWebSocket()
}
```
**Files affected:**
- `mitra_app/lib/core/chat/chat_request_bloc.dart``chat_request_notifier.dart` + `.g.dart`
- `mitra_app/lib/features/home/home_screen.dart`
- `mitra_app/lib/features/chat/widgets/incoming_request_sheet.dart`
#### 1.4.5 `mitra_app` MitraChatBloc → MitraChatNotifier
**Source file:** `mitra_app/lib/core/chat/mitra_chat_bloc.dart`
**Current:** Mirrors client ChatBloc closely. WebSocket + message list + typing + session events. 8 events, 4 states.
**Target:** `@riverpod` Notifier.
```dart
@riverpod
class MitraChat extends _$MitraChat {
WebSocketChannel? _channel;
StreamSubscription? _wsSubscription;
Timer? _typingTimer;
// State: MitraChatData
// (Initial, Connecting, Connected{messages, isOtherTyping, ...}, Error)
// build(): returns MitraChatData.initial
// connect(sessionId), disconnect(), sendMessage(content), sendTyping(),
// markDelivered(ids), markRead(ids)
}
```
**Files affected:**
- `mitra_app/lib/core/chat/mitra_chat_bloc.dart``mitra_chat_notifier.dart` + `.g.dart`
- `mitra_app/lib/features/chat/screens/mitra_chat_screen.dart`
- `mitra_app/lib/features/chat/screens/active_sessions_screen.dart`
### 1.5 Router Changes
Both apps use a `_BlocRefreshNotifier` that listens to the AuthBloc stream to trigger GoRouter redirects. Replace with a Riverpod-based approach:
```dart
class RouterNotifier extends ChangeNotifier {
RouterNotifier(this._ref) {
_ref.listen(authProvider, (_, __) => notifyListeners());
}
final Ref _ref;
}
```
Pass as `refreshListenable` to GoRouter. The redirect function reads auth state via `_ref.read(authProvider)`.
**Files changed:**
- `client_app/lib/router.dart`
- `mitra_app/lib/router.dart`
### 1.6 Code Generation
After each migration step, run:
```bash
dart run build_runner build --delete-conflicting-outputs
```
### 1.7 Final Cleanup
After all Blocs are migrated and verified:
1. Remove `flutter_bloc` and `equatable` from both `pubspec.yaml` files
2. Delete old Bloc files
3. Run `flutter pub get` to verify no remaining references
4. Global search for any remaining `BlocProvider`, `BlocBuilder`, `BlocListener`, `context.read<`, `context.watch<` — replace any stragglers
### 1.8 Testing Checklist — Riverpod Migration
| Test | App | What to verify |
|---|---|---|
| Auth flow | client_app | Anonymous login, Google login, Apple login, OTP login, account linking, logout |
| Auth flow | mitra_app | OTP login, logout, **verify stuck-loading bug is fixed** |
| Router redirect | Both | Unauthenticated → login screen; authenticated → home; splash transitions correctly |
| Pricing dialog | client_app | Pricing tiers load, free trial shows when eligible, tier selection triggers pairing |
| Pairing flow | client_app | Request pairing, searching state, bestie found transition, cancel pairing, timeout |
| Chat connect/send | client_app | WebSocket connects, messages send/receive, typing indicator, delivery/read status |
| Session closure | client_app | Extension request, decline extension → goodbye, submit goodbye |
| Status toggle | mitra_app | Go online, go offline, heartbeat fires every 15s, app lifecycle pause/resume |
| Chat requests | mitra_app | Start listening when online, incoming request sheet, accept, decline |
| Mitra chat | mitra_app | Connect to session, send/receive messages, typing, extension request handling |
| Extension | mitra_app | Accept/reject extension, goodbye message submission |
| App lifecycle | mitra_app | Backgrounding stops heartbeat, foregrounding resumes if online |
| FCM token | Both | Token registers after auth, token re-registers on app relaunch |
---
## Work Stream 2: FCM Fallback for Chat Engine
### 2.1 Database Changes
#### New `app_config` keys
| Key | Default Value (JSONB) | Purpose |
|---|---|---|
| `require_mitra_ping` | `{ "value": true }` | Whether mitra must heartbeat to stay online |
| `mitra_ping_interval_seconds` | `{ "value": 15 }` | How often mitra must ping (configurable) |
**Migration addition to `backend/src/db/migrate.js`:**
```sql
INSERT INTO app_config (key, value) VALUES ('require_mitra_ping', '{"value": true}') ON CONFLICT (key) DO NOTHING;
INSERT INTO app_config (key, value) VALUES ('mitra_ping_interval_seconds', '{"value": 15}') ON CONFLICT (key) DO NOTHING;
```
No new tables needed. Existing `chat_messages` table with `status` and `read_at` columns is sufficient for unread counts.
### 2.2 Backend Changes
#### 2.2.1 Config Service Updates
**File:** `backend/src/services/config.service.js`
Add two new functions:
- `getMitraPingConfig()` — returns `{ require_ping, ping_interval_seconds }`
- `setMitraPingConfig({ require_ping, ping_interval_seconds })` — upserts both keys
#### 2.2.2 Internal Config Routes
**File:** `backend/src/routes/internal/config.routes.js`
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/internal/config/mitra-ping` | Get require_ping + interval |
| `PATCH` | `/internal/config/mitra-ping` | Update require_ping and/or interval |
#### 2.2.3 Mitra Status Service Updates
**File:** `backend/src/services/mitra-status.service.js`
- Modify `autoOfflineStaleMitras`: if `require_ping` is `false`, skip the auto-offline sweep entirely; if `true`, use `ping_interval_seconds * 3` as the staleness threshold
- Modify `heartbeat`: if `require_ping` is `false`, return early (no-op)
- Add ping config to status GET response so mitra app knows the expected interval
#### 2.2.4 Mitra Status Routes Update
**File:** `backend/src/routes/public/mitra.status.routes.js`
Update `GET /api/mitra/status` response to include:
```json
{
"success": true,
"data": {
"is_online": true,
"require_ping": true,
"ping_interval_seconds": 15
}
}
```
#### 2.2.5 Pairing Service FCM Fallback Enhancement
**File:** `backend/src/services/pairing.service.js`
The existing `notifyMitra` already has FCM fallback. Enhancements needed:
1. FCM payload includes `session_id` for deep-linking
2. FCM notification shows confirmation that mitra must tap to accept
3. No auto-accept path from FCM — mitra must open app and manually accept
**Updated FCM payload:**
```javascript
await sendPushNotification(UserType.MITRA, mitraId, {
title: 'Permintaan Chat Baru',
body: 'Ada pelanggan yang ingin curhat! Ketuk untuk menerima.',
data: {
type: WsMessage.CHAT_REQUEST,
session_id: data.session_id,
action: 'open_accept',
},
})
```
#### 2.2.6 Closure Service FCM Fallback
**File:** `backend/src/services/closure.service.js`
In `initiateEarlyEnd` and `completeSession`, after sending WebSocket closure signals, add FCM fallback:
```javascript
if (!isUserOnlineWs(UserType.CUSTOMER, session.customer_id)) {
await sendPushNotification(UserType.CUSTOMER, session.customer_id, {
title: 'Sesi Berakhir',
body: 'Sesi curhat kamu telah berakhir. Ketuk untuk menulis pesan penutup.',
data: { type: WsMessage.SESSION_CLOSING, session_id: sessionId },
})
}
// Same for mitra
```
**File:** `backend/src/services/session-timer.service.js`
Same fix in `onSessionExpired` — add FCM fallback for both parties after `SESSION_EXPIRED` and `SESSION_CLOSING` WebSocket messages.
#### 2.2.7 Unread Count API
**New endpoints:**
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/mitra/chat-requests/sessions/active-with-unread` | Active sessions + unread count per session |
| `GET` | `/api/client/chat/session/active-with-unread` | Active session + unread count |
**File:** `backend/src/services/session.service.js`
Add `getActiveSessionsByMitraWithUnread(mitraId)` — joins `chat_sessions` with a subquery counting unread messages (where `sender_type = 'customer'` and `status IN ('sent', 'delivered')`).
Add `getActiveSessionByCustomerWithUnread(customerId)` — same pattern for customer side.
### 2.3 Flutter Changes — Mitra App
#### 2.3.1 Status Notifier Updates (Ping Config)
**File:** `mitra_app/lib/core/status/online_status_notifier.dart`
1. Fetch `require_ping` and `ping_interval_seconds` from status API response
2. If `require_ping` is `false`, do NOT start heartbeat timer
3. If `require_ping` is `true`, use `ping_interval_seconds` from config (not hardcoded 15s)
4. On `AppPaused`: if `require_ping` is false, do nothing; if true, stop heartbeat as before
#### 2.3.2 Unread Badge Provider
**New file:** `mitra_app/lib/core/chat/unread_notifier.dart`
```dart
@Riverpod(keepAlive: true)
class UnreadSessions extends _$UnreadSessions {
// Returns Map<String, int> — { sessionId: unreadCount }
// Polls every 10-30s or updates via WebSocket
// totalUnread getter: sum of all values
// markSessionRead(sessionId): optimistic update sets count to 0
}
```
#### 2.3.3 Home Screen Badge
**File:** `mitra_app/lib/features/home/home_screen.dart`
Add `Badge` widget wrapping the active sessions button icon, showing `totalUnread` count.
#### 2.3.4 Active Sessions Screen Badge
**File:** `mitra_app/lib/features/chat/screens/active_sessions_screen.dart`
Show `Badge` on each session's `ListTile` with per-session unread count. Badge clears when user enters the session (mark-read via WebSocket).
#### 2.3.5 Notification Service Updates
**File:** `mitra_app/lib/core/notifications/notification_service.dart`
Handle FCM-delivered messages:
- `type: chat_request` → navigate to home screen, show incoming request bottom sheet
- `type: session_closing` → navigate to the chat session closure screen
### 2.4 Flutter Changes — Client App
#### 2.4.1 Unread Badge Provider
**New file:** `client_app/lib/core/chat/unread_notifier.dart`
```dart
@Riverpod(keepAlive: true)
class UnreadCount extends _$UnreadCount {
// Returns int — total unread count for active session
// markRead(): sets to 0
}
```
#### 2.4.2 Home Screen Badge
**File:** `client_app/lib/features/home/home_screen.dart`
Add `Badge` widget on `_ActiveSessionCard`'s `CircleAvatar`, showing unread count.
#### 2.4.3 Notification Service Update
**File:** `client_app/lib/core/notifications/notification_service.dart`
Handle closure FCM: `type: session_closing` → navigate to chat session screen (shows closure UI).
### 2.5 Control Center Changes
**File:** `control_center/src/pages/settings/SettingsPage.jsx`
Add new section for mitra ping configuration:
- Checkbox: "Wajibkan Mitra Ping (Heartbeat)" — toggle `require_mitra_ping`
- Number input: "Interval Ping" — sets `mitra_ping_interval_seconds`
- Helper text explaining that disabling ping means QC is responsible for mitra quality
---
## 3. Implementation Order
| Step | What | Apps Affected | Dependencies |
|---|---|---|---|
| **Work Stream 1: Riverpod Migration** | | | |
| 1 | Add Riverpod dependencies to both pubspec.yaml | client_app, mitra_app | None |
| 2 | Wrap app root with ProviderScope, create ApiClient provider | client_app, mitra_app | Step 1 |
| 3 | Migrate client_app AuthBloc → AuthNotifier | client_app | Step 2 |
| 4 | Update client_app router to use Riverpod auth state | client_app | Step 3 |
| 5 | Migrate client_app ChatOpeningBloc → ChatOpeningNotifier | client_app | Step 3 |
| 6 | Migrate client_app SessionClosureBloc → SessionClosureNotifier | client_app | Step 3 |
| 7 | Migrate client_app PairingBloc → PairingNotifier | client_app | Step 3 |
| 8 | Migrate client_app ChatBloc → ChatNotifier | client_app | Step 3, 6, 7 |
| 9 | E2E test client_app (all flows) | client_app | Steps 38 |
| 10 | Migrate mitra_app AuthBloc → AuthNotifier (**fix stuck-loading bug**) | mitra_app | Step 2 |
| 11 | Update mitra_app router to use Riverpod auth state | mitra_app | Step 10 |
| 12 | Migrate mitra_app StatusBloc → StatusNotifier | mitra_app | Step 10 |
| 13 | Migrate mitra_app ExtensionBloc → ExtensionNotifier | mitra_app | Step 10 |
| 14 | Migrate mitra_app ChatRequestBloc → ChatRequestNotifier | mitra_app | Step 10, 12 |
| 15 | Migrate mitra_app MitraChatBloc → MitraChatNotifier | mitra_app | Step 10, 13 |
| 16 | E2E test mitra_app (all flows + verify bug fix) | mitra_app | Steps 1015 |
| 17 | Remove flutter_bloc + equatable from both apps | client_app, mitra_app | Steps 9, 16 |
| **Work Stream 2: FCM Fallback** | | | |
| 18 | DB migration: add `require_mitra_ping` + `mitra_ping_interval_seconds` config | Backend | None |
| 19 | Config service: add get/set for mitra ping config | Backend | Step 18 |
| 20 | Internal config routes: add GET/PATCH `/internal/config/mitra-ping` | Backend | Step 19 |
| 21 | Control center: add mitra ping config section to Settings | Control center | Step 20 |
| 22 | Mitra status service: honor `require_mitra_ping` in auto-offline + heartbeat | Backend | Step 19 |
| 23 | Mitra status routes: include ping config in GET response | Backend | Step 22 |
| 24 | Mitra app StatusNotifier: use dynamic ping config from API | mitra_app | Step 23, 12 |
| 25 | Pairing service: enhance FCM payload for chat request | Backend | Existing |
| 26 | Mitra app NotificationService: handle FCM chat requests | mitra_app | Step 25, 14 |
| 27 | Closure service: add FCM fallback for session_closing signal | Backend | Existing |
| 28 | Session timer service: add FCM fallback for session_expired signal | Backend | Existing |
| 29 | Client/mitra app NotificationService: handle closure FCM | Both apps | Steps 2728 |
| 30 | Unread count API: add session service functions + routes | Backend | Existing |
| 31 | Mitra app: UnreadSessions provider + badges | mitra_app | Step 30 |
| 32 | Client app: UnreadCount provider + badge | client_app | Step 30 |
| 33 | E2E test: mitra ping config + non-ping mode + pairing via FCM | All | Steps 2126 |
| 34 | E2E test: closure FCM fallback + unread badges | All | Steps 2732 |
---
## 4. New Dependencies
| App | Package | Purpose |
|---|---|---|
| client_app | `flutter_riverpod` | Core Riverpod |
| client_app | `hooks_riverpod` | Riverpod + Hooks integration |
| client_app | `riverpod_annotation` | `@riverpod` annotations |
| client_app | `flutter_hooks` | Hook utilities |
| client_app (dev) | `riverpod_generator` | Code generation |
| client_app (dev) | `build_runner` | Code generation runner |
| client_app (dev) | `custom_lint` | Required for riverpod_lint |
| client_app (dev) | `riverpod_lint` | Lint rules |
| mitra_app | Same as client_app | Same |
| mitra_app (dev) | Same as client_app | Same |
**Removed after migration:**
| App | Package | Reason |
|---|---|---|
| client_app | `flutter_bloc` | Replaced by Riverpod |
| client_app | `equatable` | No longer needed |
| mitra_app | `flutter_bloc` | Replaced by Riverpod |
| mitra_app | `equatable` | No longer needed |
No new backend or control_center dependencies.
---
## 5. Risks & Mitigations
| Risk | Mitigation |
|---|---|
| Riverpod migration breaks auth redirect logic | Test router redirects thoroughly after step 4/11; keep old bloc files until verified |
| WebSocket lifecycle differs between BLoC and Notifier | BLoC `close()` auto-called on `BlocProvider` dispose; Riverpod notifiers with `keepAlive: true` persist. Ensure `ref.onDispose()` cleans up WebSocket/timers |
| Code generation conflicts | Run `build_runner build --delete-conflicting-outputs` after each migration step |
| FCM notifications not received when app is killed | Already handled by `firebase_messaging` background handler; verify on both iOS and Android |
| Non-ping mode mitras go stale in database | When `require_ping` is false, auto-offline sweep is completely skipped; only manual offline or Control Center action changes status |
| Unread count polling creates excessive API calls | Use 10-30s polling interval; WebSocket-based real-time update can be added later |

84
requirement/phase3.1.md Normal file
View File

@@ -0,0 +1,84 @@
# PRD: Phase 3 Stabilization & State Management Migration
# Overview
**Goal:** Stabilize Phase 3 (Chat Engine) through end-to-end testing and migrate Flutter state management from BLoC to Riverpod + flutter_hooks
**Success looks like:** All Phase 3 features are verified working end-to-end across client_app, mitra_app, and control_center. Both Flutter apps use Riverpod as their sole state management solution.
## Background
- Phase 3 (Chat Engine) is fully scaffolded but has not been end-to-end tested
- Current Flutter apps use BLoC pattern; Riverpod is preferred for maintainability and reduced boilerplate
- Migration should happen before Phase 4 to avoid compounding tech debt
## FCM fallback for Chat Engine
### Mitra Pairing
- Add configuration on Control center to configure Mitra's app require to ping or not.
- When Control Center allow non ping, application or backend will not force mitra to ping and allow them to keep online even when the app is closed or in backround
- Modify Mitra Pairing confirmation to send notification through FCM when websocket to Mitra is closed
### Bi-Directional Chat (WebSocket + FCM)
#### Mitra App
- When there is new unread message, mitra app must shows badge on active session
- When there is new unread message, mitra app must shows badge on the chat active session inside active session page
- Unread badge on each active session will be cleared when the message has been read
- Unread badge on active session button on main page will be cleared when the message has been read
#### Customer App
- When there is new unread message, Customer app must shows badge on active session
- Unread badge will be cleared when unread message has been cleared
### Chat Closure & Extension
- When chat closure called, backend will send closure signal to both Mitra and Customer
- Backend will use FCM if the websocket connection is down
### Control Center
- Control center shows configuration for ping from mitra
## Riverpod Migration
### Scope
- Migrate all BLoC classes in `client_app` and `mitra_app` to Riverpod annotation-based providers
- Replace `flutter_bloc` with `flutter_riverpod`, `riverpod_annotation`, `flutter_hooks`, and `hooks_riverpod`
- Add `riverpod_generator` + `build_runner` as dev dependencies for code generation
- No backend or control_center changes
### Migration Strategy
- [ ] Add Riverpod dependencies (`flutter_riverpod`, `hooks_riverpod`, `riverpod_annotation`) and dev dependencies (`riverpod_generator`, `build_runner`, `custom_lint`, `riverpod_lint`)
- [ ] Wrap app root with `ProviderScope`
- [ ] Migrate one Bloc at a time, starting with the simplest (e.g. AuthBloc)
- [ ] For each migrated Bloc:
1. Replace `Bloc`/`Cubit` class with `@riverpod` annotated `Notifier` or `AsyncNotifier` (extending `_$ClassName`)
2. Replace `BlocEvent` + `emit()` pattern with notifier methods that update `state` directly
3. Run `dart run build_runner build` to generate `.g.dart` files
4. Replace `BlocProvider` with generated provider (e.g. `authProvider`)
5. Replace `BlocBuilder` widgets with `ConsumerWidget` + `ref.watch()`
6. Replace `BlocListener` with `ref.listen()` inside widget or provider
7. Use `HookConsumerWidget` where flutter_hooks are needed (e.g. `useTextEditingController`, `useEffect`)
- [ ] Run E2E verification after each migration to catch regressions
- [ ] Remove `flutter_bloc` dependency only after all Blocs are migrated
### Affected Blocs
- [ ] `client_app` — AuthBloc, PairingBloc, ChatBloc, ChatOpeningBloc, SessionClosureBloc
- [ ] `mitra_app` — AuthBloc, OnlineStatusBloc, MitraChatBloc, ExtensionBloc
# Non-Functional Requirement
- [ ] WebSocket reconnects gracefully after network interruption (within 5s on stable network)
- [ ] Use FCM to send command or message when websocket is down
- [ ] No message loss during brief disconnects — undelivered messages sync on reconnect
- [ ] Chat screen maintains scroll position and input draft on app lifecycle events (background/foreground)
- [ ] Riverpod migration introduces zero new UI bugs — feature parity with BLoC implementation
# Tech Stack
- State management: Riverpod + flutter_hooks (replacing flutter_bloc)
- No backend changes expected — migration is Flutter-only