diff --git a/client_app/android/app/src/main/AndroidManifest.xml b/client_app/android/app/src/main/AndroidManifest.xml index 84e01a7..a296cfb 100644 --- a/client_app/android/app/src/main/AndroidManifest.xml +++ b/client_app/android/app/src/main/AndroidManifest.xml @@ -2,7 +2,8 @@ + android:icon="@mipmap/ic_launcher" + android:usesCleartextTraffic="true"> ApiClient(); diff --git a/client_app/lib/core/api/api_client_provider.g.dart b/client_app/lib/core/api/api_client_provider.g.dart new file mode 100644 index 0000000..c46826e --- /dev/null +++ b/client_app/lib/core/api/api_client_provider.g.dart @@ -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.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; +// 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 diff --git a/client_app/lib/core/auth/auth_notifier.dart b/client_app/lib/core/auth/auth_notifier.dart new file mode 100644 index 0000000..253be92 --- /dev/null +++ b/client_app/lib/core/auth/auth_notifier.dart @@ -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 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 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 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; + 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 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 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 requestOtp(String phone) async { + state = const AsyncLoading(); + final completer = Completer(); + 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 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 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 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 _verifyAndReturn() async { + final response = await _apiClient.post('/api/client/auth/verify'); + return AuthAuthenticatedData(response['data'] as Map); + } +} diff --git a/client_app/lib/core/auth/auth_notifier.g.dart b/client_app/lib/core/auth/auth_notifier.g.dart new file mode 100644 index 0000000..f78f42a --- /dev/null +++ b/client_app/lib/core/auth/auth_notifier.g.dart @@ -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.internal( + Auth.new, + name: r'authProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$authHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$Auth = AsyncNotifier; +// 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 diff --git a/client_app/lib/core/chat/chat_opening_provider.dart b/client_app/lib/core/chat/chat_opening_provider.dart new file mode 100644 index 0000000..71e27df --- /dev/null +++ b/client_app/lib/core/chat/chat_opening_provider.dart @@ -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 json) { + return PriceTier( + durationMinutes: json['duration_minutes'] as int, + price: json['price'] as int, + label: json['label'] as String, + ); + } +} + +class PricingData { + final List tiers; + final bool freeTrialEligible; + final int freeTrialDurationMinutes; + + const PricingData({ + required this.tiers, + required this.freeTrialEligible, + this.freeTrialDurationMinutes = 5, + }); +} + +@riverpod +Future chatPricing(Ref ref) async { + final apiClient = ref.read(apiClientProvider); + final response = await apiClient.get('/api/client/chat/pricing'); + final data = response['data'] as Map; + final tiersJson = data['tiers'] as List; + final tiers = tiersJson.map((t) => PriceTier.fromJson(t as Map)).toList(); + final freeTrial = data['free_trial'] as Map; + + return PricingData( + tiers: tiers, + freeTrialEligible: freeTrial['eligible'] as bool? ?? false, + freeTrialDurationMinutes: freeTrial['duration_minutes'] as int? ?? 5, + ); +} diff --git a/client_app/lib/core/chat/chat_opening_provider.g.dart b/client_app/lib/core/chat/chat_opening_provider.g.dart new file mode 100644 index 0000000..ba45584 --- /dev/null +++ b/client_app/lib/core/chat/chat_opening_provider.g.dart @@ -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.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; +// 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 diff --git a/client_app/lib/features/auth/screens/display_name_screen.dart b/client_app/lib/features/auth/screens/display_name_screen.dart index eb0ba2d..7834b14 100644 --- a/client_app/lib/features/auth/screens/display_name_screen.dart +++ b/client_app/lib/features/auth/screens/display_name_screen.dart @@ -1,15 +1,15 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../core/auth/auth_bloc.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/auth/auth_notifier.dart'; -class DisplayNameScreen extends StatefulWidget { +class DisplayNameScreen extends ConsumerStatefulWidget { const DisplayNameScreen({super.key}); @override - State createState() => _DisplayNameScreenState(); + ConsumerState createState() => _DisplayNameScreenState(); } -class _DisplayNameScreenState extends State { +class _DisplayNameScreenState extends ConsumerState { final _controller = TextEditingController(); @override @@ -21,46 +21,46 @@ class _DisplayNameScreenState extends State { void _submit() { final name = _controller.text.trim(); if (name.isEmpty) return; - context.read().add(AnonymousLoginRequested(name)); + ref.read(authProvider.notifier).loginAnonymous(name); } @override Widget build(BuildContext context) { - return BlocListener( - listener: (context, state) { - if (state is AuthError) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message))); - } - }, - child: Scaffold( - appBar: AppBar(title: const Text('Siapa namamu?')), - body: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text('Pilih nama yang ingin kamu gunakan. Nama ini tidak akan terlihat oleh siapapun selain mitra kamu.'), - const SizedBox(height: 24), - TextField( - controller: _controller, - decoration: const InputDecoration( - labelText: 'Nama panggilan', - border: OutlineInputBorder(), - ), - textInputAction: TextInputAction.done, - onSubmitted: (_) => _submit(), + final authState = ref.watch(authProvider); + final isLoading = authState is AsyncLoading; + + ref.listen(authProvider, (prev, next) { + if (next is AsyncError) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString()))); + } + }); + + return Scaffold( + appBar: AppBar(title: const Text('Siapa namamu?')), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text('Pilih nama yang ingin kamu gunakan. Nama ini tidak akan terlihat oleh siapapun selain mitra kamu.'), + const SizedBox(height: 24), + TextField( + controller: _controller, + decoration: const InputDecoration( + labelText: 'Nama panggilan', + border: OutlineInputBorder(), ), - const SizedBox(height: 24), - BlocBuilder( - builder: (context, state) => ElevatedButton( - onPressed: state is AuthLoading ? null : _submit, - child: state is AuthLoading - ? const CircularProgressIndicator() - : const Text('Lanjut'), - ), - ), - ], - ), + textInputAction: TextInputAction.done, + onSubmitted: (_) => _submit(), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: isLoading ? null : _submit, + child: isLoading + ? const CircularProgressIndicator() + : const Text('Lanjut'), + ), + ], ), ), ); diff --git a/client_app/lib/features/auth/screens/force_register_screen.dart b/client_app/lib/features/auth/screens/force_register_screen.dart index 0f16b48..07e4382 100644 --- a/client_app/lib/features/auth/screens/force_register_screen.dart +++ b/client_app/lib/features/auth/screens/force_register_screen.dart @@ -1,18 +1,18 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import '../../../core/auth/auth_bloc.dart'; +import '../../../core/auth/auth_notifier.dart'; /// Shown when anonymity is disabled by admin. /// User must link their account. Display name is pre-filled. -class ForceRegisterScreen extends StatefulWidget { +class ForceRegisterScreen extends ConsumerStatefulWidget { const ForceRegisterScreen({super.key}); @override - State createState() => _ForceRegisterScreenState(); + ConsumerState createState() => _ForceRegisterScreenState(); } -class _ForceRegisterScreenState extends State { +class _ForceRegisterScreenState extends ConsumerState { final _phoneController = TextEditingController(); @override @@ -23,80 +23,77 @@ class _ForceRegisterScreenState extends State { @override Widget build(BuildContext context) { - return BlocListener( - listener: (context, state) { - if (state is AuthOtpSent) { - context.push('/auth/otp', extra: _phoneController.text.trim()); - } - if (state is AuthAuthenticated) { - // After linking, link account to existing anonymous record - context.read().add(LinkAccountRequested()); - } - if (state is AuthError) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message))); - } - }, - child: Scaffold( - appBar: AppBar(title: const Text('Verifikasi Akun')), - body: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text( - 'Untuk melanjutkan, kamu perlu mendaftarkan akun.', - style: TextStyle(fontSize: 16), + final authState = ref.watch(authProvider); + final isLoading = authState is AsyncLoading; + + ref.listen(authProvider, (prev, next) { + final data = next.valueOrNull; + if (data is AuthOtpSentData) { + context.push('/auth/otp', extra: _phoneController.text.trim()); + } + if (data is AuthAuthenticatedData) { + // After social login succeeds, link account to existing anonymous record + ref.read(authProvider.notifier).linkAccount(); + } + if (next is AsyncError) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString()))); + } + }); + + return Scaffold( + appBar: AppBar(title: const Text('Verifikasi Akun')), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Untuk melanjutkan, kamu perlu mendaftarkan akun.', + style: TextStyle(fontSize: 16), + ), + const SizedBox(height: 24), + ElevatedButton.icon( + icon: const Icon(Icons.g_mobiledata), + onPressed: isLoading ? null + : () => ref.read(authProvider.notifier).loginGoogle(), + label: const Text('Lanjut dengan Google'), + ), + const SizedBox(height: 12), + ElevatedButton.icon( + icon: const Icon(Icons.apple), + onPressed: isLoading ? null + : () => ref.read(authProvider.notifier).loginApple(), + label: const Text('Lanjut dengan Apple'), + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Row(children: [ + Expanded(child: Divider()), + Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')), + Expanded(child: Divider()), + ]), + ), + TextField( + controller: _phoneController, + decoration: const InputDecoration( + labelText: 'Nomor HP', + hintText: '+628xxxxxxxxxx', + border: OutlineInputBorder(), ), - const SizedBox(height: 24), - BlocBuilder( - builder: (context, state) => ElevatedButton.icon( - icon: const Icon(Icons.g_mobiledata), - onPressed: state is AuthLoading ? null - : () => context.read().add(GoogleLoginRequested()), - label: const Text('Lanjut dengan Google'), - ), - ), - const SizedBox(height: 12), - BlocBuilder( - builder: (context, state) => ElevatedButton.icon( - icon: const Icon(Icons.apple), - onPressed: state is AuthLoading ? null - : () => context.read().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( - builder: (context, state) => ElevatedButton( - onPressed: state is AuthLoading ? null : () { - final phone = _phoneController.text.trim(); - if (phone.isEmpty) return; - context.read().add(PhoneOtpRequested(phone)); - }, - child: state is AuthLoading - ? const CircularProgressIndicator() - : const Text('Kirim OTP'), - ), - ), - ], - ), + keyboardType: TextInputType.phone, + ), + const SizedBox(height: 12), + ElevatedButton( + onPressed: isLoading ? null : () { + final phone = _phoneController.text.trim(); + if (phone.isEmpty) return; + ref.read(authProvider.notifier).requestOtp(phone); + }, + child: isLoading + ? const CircularProgressIndicator() + : const Text('Kirim OTP'), + ), + ], ), ), ); diff --git a/client_app/lib/features/auth/screens/otp_screen.dart b/client_app/lib/features/auth/screens/otp_screen.dart index ac62093..cea63e2 100644 --- a/client_app/lib/features/auth/screens/otp_screen.dart +++ b/client_app/lib/features/auth/screens/otp_screen.dart @@ -1,17 +1,28 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../core/auth/auth_bloc.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/auth/auth_notifier.dart'; -class OtpScreen extends StatefulWidget { +class OtpScreen extends ConsumerStatefulWidget { final String phone; const OtpScreen({super.key, required this.phone}); @override - State createState() => _OtpScreenState(); + ConsumerState createState() => _OtpScreenState(); } -class _OtpScreenState extends State { +class _OtpScreenState extends ConsumerState { final _otpController = TextEditingController(); + String? _verificationId; + + @override + void initState() { + super.initState(); + // Capture verification ID from current state + final data = ref.read(authProvider).valueOrNull; + if (data is AuthOtpSentData) { + _verificationId = data.verificationId; + } + } @override void dispose() { @@ -21,46 +32,51 @@ class _OtpScreenState extends State { @override Widget build(BuildContext context) { - return BlocListener( - listener: (context, state) { - if (state is AuthError) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message))); - } - }, - child: Scaffold( - appBar: AppBar(title: const Text('Masukkan OTP')), - body: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text('Kode OTP telah dikirim ke ${widget.phone}'), - const SizedBox(height: 24), - TextField( - controller: _otpController, - decoration: const InputDecoration( - labelText: 'Kode OTP', - border: OutlineInputBorder(), - ), - keyboardType: TextInputType.number, - maxLength: 6, + final authState = ref.watch(authProvider); + final isLoading = authState is AsyncLoading; + + // Update verification ID if state changes + final data = authState.valueOrNull; + if (data is AuthOtpSentData) { + _verificationId = data.verificationId; + } + + ref.listen(authProvider, (prev, next) { + if (next is AsyncError) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString()))); + } + }); + + return Scaffold( + appBar: AppBar(title: const Text('Masukkan OTP')), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Kode OTP telah dikirim ke ${widget.phone}'), + const SizedBox(height: 24), + TextField( + controller: _otpController, + decoration: const InputDecoration( + labelText: 'Kode OTP', + border: OutlineInputBorder(), ), - const SizedBox(height: 12), - BlocBuilder( - builder: (context, state) => ElevatedButton( - onPressed: state is AuthLoading ? null : () { - final otp = _otpController.text.trim(); - if (otp.length != 6) return; - final verificationId = (state is AuthOtpSent) ? state.verificationId : ''; - context.read().add(OtpVerified(verificationId, otp)); - }, - child: state is AuthLoading - ? const CircularProgressIndicator() - : const Text('Verifikasi'), - ), - ), - ], - ), + keyboardType: TextInputType.number, + maxLength: 6, + ), + const SizedBox(height: 12), + ElevatedButton( + onPressed: isLoading ? null : () { + final otp = _otpController.text.trim(); + if (otp.length != 6 || _verificationId == null) return; + ref.read(authProvider.notifier).verifyOtp(_verificationId!, otp); + }, + child: isLoading + ? const CircularProgressIndicator() + : const Text('Verifikasi'), + ), + ], ), ), ); diff --git a/client_app/lib/features/auth/screens/register_screen.dart b/client_app/lib/features/auth/screens/register_screen.dart index 09b9ba1..d4dc47e 100644 --- a/client_app/lib/features/auth/screens/register_screen.dart +++ b/client_app/lib/features/auth/screens/register_screen.dart @@ -1,16 +1,16 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import '../../../core/auth/auth_bloc.dart'; +import '../../../core/auth/auth_notifier.dart'; -class RegisterScreen extends StatefulWidget { +class RegisterScreen extends ConsumerStatefulWidget { const RegisterScreen({super.key}); @override - State createState() => _RegisterScreenState(); + ConsumerState createState() => _RegisterScreenState(); } -class _RegisterScreenState extends State { +class _RegisterScreenState extends ConsumerState { final _phoneController = TextEditingController(); @override @@ -21,71 +21,68 @@ class _RegisterScreenState extends State { @override Widget build(BuildContext context) { - return BlocListener( - listener: (context, state) { - if (state is AuthOtpSent) { - context.push('/auth/otp', extra: _phoneController.text.trim()); - } - if (state is AuthError) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message))); - } - }, - child: Scaffold( - appBar: AppBar(title: const Text('Masuk / Daftar')), - body: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - BlocBuilder( - builder: (context, state) => ElevatedButton.icon( - icon: const Icon(Icons.g_mobiledata), - onPressed: state is AuthLoading ? null - : () => context.read().add(GoogleLoginRequested()), - label: const Text('Lanjut dengan Google'), - ), + final authState = ref.watch(authProvider); + final isLoading = authState is AsyncLoading; + + ref.listen(authProvider, (prev, next) { + final data = next.valueOrNull; + if (data is AuthOtpSentData) { + context.push('/auth/otp', extra: _phoneController.text.trim()); + } + if (next is AsyncError) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString()))); + } + }); + + return Scaffold( + appBar: AppBar(title: const Text('Masuk / Daftar')), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ElevatedButton.icon( + icon: const Icon(Icons.g_mobiledata), + onPressed: isLoading ? null + : () => ref.read(authProvider.notifier).loginGoogle(), + label: const Text('Lanjut dengan Google'), + ), + const SizedBox(height: 12), + ElevatedButton.icon( + icon: const Icon(Icons.apple), + onPressed: isLoading ? null + : () => ref.read(authProvider.notifier).loginApple(), + label: const Text('Lanjut dengan Apple'), + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Row(children: [ + Expanded(child: Divider()), + Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')), + Expanded(child: Divider()), + ]), + ), + TextField( + controller: _phoneController, + decoration: const InputDecoration( + labelText: 'Nomor HP', + hintText: '+628xxxxxxxxxx', + border: OutlineInputBorder(), ), - const SizedBox(height: 12), - BlocBuilder( - builder: (context, state) => ElevatedButton.icon( - icon: const Icon(Icons.apple), - onPressed: state is AuthLoading ? null - : () => context.read().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( - builder: (context, state) => ElevatedButton( - onPressed: state is AuthLoading ? null : () { - final phone = _phoneController.text.trim(); - if (phone.isEmpty) return; - context.read().add(PhoneOtpRequested(phone)); - }, - child: state is AuthLoading - ? const CircularProgressIndicator() - : const Text('Kirim OTP'), - ), - ), - ], - ), + keyboardType: TextInputType.phone, + ), + const SizedBox(height: 12), + ElevatedButton( + onPressed: isLoading ? null : () { + final phone = _phoneController.text.trim(); + if (phone.isEmpty) return; + ref.read(authProvider.notifier).requestOtp(phone); + }, + child: isLoading + ? const CircularProgressIndicator() + : const Text('Kirim OTP'), + ), + ], ), ), ); diff --git a/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart b/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart index 6bc9062..3674707 100644 --- a/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart +++ b/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../core/api/api_client.dart'; -import '../../../core/chat/chat_opening_bloc.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/chat/chat_opening_provider.dart'; import '../../../core/chat/session_closure_bloc.dart'; import '../../../core/pairing/pairing_bloc.dart'; -class PricingBottomSheet extends StatelessWidget { +class PricingBottomSheet extends ConsumerWidget { /// If set, the bottom sheet is in "extension" mode — selecting a tier extends the session. final String? extensionSessionId; @@ -16,14 +16,11 @@ class PricingBottomSheet extends StatelessWidget { return showModalBottomSheet( context: context, isScrollControlled: true, - builder: (_) => BlocProvider( - create: (ctx) => ChatOpeningBloc(apiClient: ctx.read())..add(LoadPricing()), - child: MultiBlocProvider( - providers: [ - BlocProvider.value(value: context.read()), - ], - child: const PricingBottomSheet(), - ), + builder: (_) => MultiBlocProvider( + providers: [ + BlocProvider.value(value: context.read()), + ], + child: const PricingBottomSheet(), ), ); } @@ -33,14 +30,11 @@ class PricingBottomSheet extends StatelessWidget { return showModalBottomSheet( context: context, isScrollControlled: true, - builder: (_) => BlocProvider( - create: (ctx) => ChatOpeningBloc(apiClient: ctx.read())..add(LoadPricing()), - child: MultiBlocProvider( - providers: [ - BlocProvider.value(value: context.read()), - ], - child: PricingBottomSheet(extensionSessionId: sessionId), - ), + builder: (_) => MultiBlocProvider( + providers: [ + BlocProvider.value(value: context.read()), + ], + child: PricingBottomSheet(extensionSessionId: sessionId), ), ); } @@ -56,94 +50,83 @@ class PricingBottomSheet extends StatelessWidget { } @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final isExtension = extensionSessionId != null; + final pricingAsync = ref.watch(chatPricingProvider); - return BlocBuilder( - builder: (context, state) { - if (state is PricingLoading || state is PricingInitial) { - return const SizedBox( - height: 200, - child: Center(child: CircularProgressIndicator()), - ); - } - - if (state is PricingError) { - return SizedBox( - height: 200, - child: Center(child: Text(state.message)), - ); - } - - if (state is PricingLoaded) { - return DraggableScrollableSheet( - initialChildSize: 0.6, - minChildSize: 0.4, - maxChildSize: 0.8, - expand: false, - builder: (_, scrollController) { - return Padding( - padding: const EdgeInsets.all(24), - child: ListView( - controller: scrollController, - children: [ - Text( - isExtension ? 'Perpanjang Durasi' : 'Pilih Durasi Curhat', - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - if (!isExtension && state.freeTrialEligible) ...[ - Card( - color: Colors.green.shade50, - child: ListTile( - leading: const Icon(Icons.card_giftcard, color: Colors.green), - title: Text('Free Trial (${state.freeTrialDurationMinutes} Menit)'), - subtitle: const Text('Gratis untuk pertama kali!'), - trailing: const Icon(Icons.arrow_forward_ios, size: 16), - onTap: () { - Navigator.of(context).pop(); - _startPairing(context, isFreeTrial: true); - }, - ), - ), - const Divider(height: 24), - ], - ...state.tiers.map((tier) => Card( - child: ListTile( - title: Text(tier.label), - trailing: Text( - _formatPrice(tier.price), - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), - ), - onTap: () { - Navigator.of(context).pop(); - if (isExtension) { - _requestExtension( - context, - sessionId: extensionSessionId!, - durationMinutes: tier.durationMinutes, - price: tier.price, - ); - } else { - _startPairing( - context, - durationMinutes: tier.durationMinutes, - price: tier.price, - ); - } - }, - ), - )), - ], + return pricingAsync.when( + loading: () => const SizedBox( + height: 200, + child: Center(child: CircularProgressIndicator()), + ), + error: (error, _) => SizedBox( + height: 200, + child: Center(child: Text('Gagal memuat harga. Coba lagi.')), + ), + data: (pricing) => DraggableScrollableSheet( + initialChildSize: 0.6, + minChildSize: 0.4, + maxChildSize: 0.8, + expand: false, + builder: (_, scrollController) { + return Padding( + padding: const EdgeInsets.all(24), + child: ListView( + controller: scrollController, + children: [ + Text( + isExtension ? 'Perpanjang Durasi' : 'Pilih Durasi Curhat', + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, ), - ); - }, + const SizedBox(height: 16), + if (!isExtension && pricing.freeTrialEligible) ...[ + Card( + color: Colors.green.shade50, + child: ListTile( + leading: const Icon(Icons.card_giftcard, color: Colors.green), + title: Text('Free Trial (${pricing.freeTrialDurationMinutes} Menit)'), + subtitle: const Text('Gratis untuk pertama kali!'), + trailing: const Icon(Icons.arrow_forward_ios, size: 16), + onTap: () { + Navigator.of(context).pop(); + _startPairing(context, isFreeTrial: true); + }, + ), + ), + const Divider(height: 24), + ], + ...pricing.tiers.map((tier) => Card( + child: ListTile( + title: Text(tier.label), + trailing: Text( + _formatPrice(tier.price), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + onTap: () { + Navigator.of(context).pop(); + if (isExtension) { + _requestExtension( + context, + sessionId: extensionSessionId!, + durationMinutes: tier.durationMinutes, + price: tier.price, + ); + } else { + _startPairing( + context, + durationMinutes: tier.durationMinutes, + price: tier.price, + ); + } + }, + ), + )), + ], + ), ); - } - - return const SizedBox.shrink(); - }, + }, + ), ); } diff --git a/client_app/lib/features/home/home_screen.dart b/client_app/lib/features/home/home_screen.dart index 0fdfea7..7b6982f 100644 --- a/client_app/lib/features/home/home_screen.dart +++ b/client_app/lib/features/home/home_screen.dart @@ -1,19 +1,20 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import '../../core/auth/auth_bloc.dart'; -import '../../core/api/api_client.dart'; +import '../../core/auth/auth_notifier.dart'; +import '../../core/api/api_client_provider.dart'; import '../../core/pairing/pairing_bloc.dart'; import '../chat/widgets/pricing_bottom_sheet.dart'; -class HomeScreen extends StatefulWidget { +class HomeScreen extends ConsumerStatefulWidget { const HomeScreen({super.key}); @override - State createState() => _HomeScreenState(); + ConsumerState createState() => _HomeScreenState(); } -class _HomeScreenState extends State with WidgetsBindingObserver { +class _HomeScreenState extends ConsumerState with WidgetsBindingObserver { Map? _activeSession; bool _loadingSession = true; @@ -40,13 +41,12 @@ class _HomeScreenState extends State with WidgetsBindingObserver { @override void didChangeDependencies() { super.didChangeDependencies(); - // Re-check when navigating back to this screen _checkActiveSession(); } Future _checkActiveSession() async { try { - final apiClient = context.read(); + final apiClient = ref.read(apiClientProvider); final response = await apiClient.get('/api/client/chat/session/active'); final data = response['data']; if (mounted) { @@ -62,6 +62,15 @@ class _HomeScreenState extends State with WidgetsBindingObserver { @override Widget build(BuildContext context) { + final authState = ref.watch(authProvider); + final authData = authState.valueOrNull; + + final displayName = switch (authData) { + AuthAuthenticatedData d => d.profile['display_name'] as String? ?? '', + AuthAnonymousData d => d.displayName, + _ => '', + }; + return BlocListener( listener: (context, state) { if (state is PairingSearching) { @@ -74,63 +83,53 @@ class _HomeScreenState extends State with WidgetsBindingObserver { ); } }, - child: BlocBuilder( - builder: (context, state) { - final displayName = state is AuthAuthenticated - ? state.profile['display_name'] as String - : state is AuthAnonymous - ? state.displayName - : ''; - - return Scaffold( - appBar: AppBar( - title: const Text('Halo Bestie'), - actions: [ - IconButton( - icon: const Icon(Icons.history), - onPressed: () => context.push('/chat/history'), - ), - IconButton( - icon: const Icon(Icons.logout), - onPressed: () => context.read().add(LogoutRequested()), - ), + child: Scaffold( + appBar: AppBar( + title: const Text('Halo Bestie'), + actions: [ + IconButton( + icon: const Icon(Icons.history), + onPressed: () => context.push('/chat/history'), + ), + IconButton( + icon: const Icon(Icons.logout), + onPressed: () => ref.read(authProvider.notifier).logout(), + ), + ], + ), + body: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)), + const SizedBox(height: 32), + if (_loadingSession) + const CircularProgressIndicator() + else if (_activeSession != null) + _ActiveSessionCard( + session: _activeSession!, + onTap: () { + final sessionId = _activeSession!['id'] as String; + final mitraName = _activeSession!['mitra_display_name'] as String? ?? 'Bestie'; + context.push('/chat/session/$sessionId', extra: mitraName); + }, + ) + else ...[ + const SizedBox(height: 16), + ElevatedButton( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16), + ), + onPressed: () => PricingBottomSheet.show(context), + child: const Text('Mulai Curhat', style: TextStyle(fontSize: 18)), + ), + ], ], ), - body: Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)), - const SizedBox(height: 32), - if (_loadingSession) - const CircularProgressIndicator() - else if (_activeSession != null) - _ActiveSessionCard( - session: _activeSession!, - onTap: () { - final sessionId = _activeSession!['id'] as String; - final mitraName = _activeSession!['mitra_display_name'] as String? ?? 'Bestie'; - context.push('/chat/session/$sessionId', extra: mitraName); - }, - ) - else ...[ - const SizedBox(height: 16), - ElevatedButton( - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16), - ), - onPressed: () => PricingBottomSheet.show(context), - child: const Text('Mulai Curhat', style: TextStyle(fontSize: 18)), - ), - ], - ], - ), - ), - ), - ); - }, + ), + ), ), ); } diff --git a/client_app/lib/main.dart b/client_app/lib/main.dart index dbecfdd..2768ddb 100644 --- a/client_app/lib/main.dart +++ b/client_app/lib/main.dart @@ -2,9 +2,9 @@ import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; -import 'core/api/api_client.dart'; -import 'core/auth/auth_bloc.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'core/api/api_client_provider.dart'; +import 'core/auth/auth_notifier.dart'; import 'core/chat/chat_bloc.dart'; import 'core/chat/session_closure_bloc.dart'; import 'core/pairing/pairing_bloc.dart'; @@ -16,68 +16,64 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); - // Request notification permission final messaging = FirebaseMessaging.instance; await messaging.requestPermission(); - runApp(const App()); + runApp(const ProviderScope(child: App())); } -class App extends StatefulWidget { +class App extends ConsumerStatefulWidget { const App({super.key}); @override - State createState() => _AppState(); + ConsumerState createState() => _AppState(); } -class _AppState extends State { - final _apiClient = ApiClient(); - late final AuthBloc _authBloc; - late final GoRouter _router; +class _AppState extends ConsumerState { + bool _fcmRegistered = false; - @override - void initState() { - super.initState(); - _authBloc = AuthBloc(apiClient: _apiClient)..add(AppStarted()); - _router = buildRouter(_authBloc); - NotificationService.initialize(_router); - _registerFcmToken(); - } - - Future _registerFcmToken() async { - // Listen for auth state, then register token - _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 (_) {} + void _registerFcmToken() { + if (_fcmRegistered) return; + _fcmRegistered = true; + Future(() async { + try { + final token = await FirebaseMessaging.instance.getToken(); + if (token != null) { + await ref.read(apiClientProvider).post('/api/shared/device-token', data: {'token': token}); + } + } catch (_) { + _fcmRegistered = false; } }); } - @override - void dispose() { - _authBloc.close(); - _router.dispose(); - super.dispose(); - } - @override 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( providers: [ - BlocProvider.value(value: _authBloc), - BlocProvider(create: (_) => PairingBloc(apiClient: _apiClient)), - BlocProvider(create: (_) => ChatBloc(apiClient: _apiClient)), - BlocProvider(create: (_) => SessionClosureBloc(apiClient: _apiClient)), - RepositoryProvider.value(value: _apiClient), + BlocProvider(create: (_) => PairingBloc(apiClient: apiClient)), + BlocProvider(create: (_) => ChatBloc(apiClient: apiClient)), + BlocProvider(create: (_) => SessionClosureBloc(apiClient: apiClient)), + RepositoryProvider.value(value: apiClient), ], child: MaterialApp.router( title: 'Halo Bestie', - routerConfig: _router, + routerConfig: router, ), ); } diff --git a/client_app/lib/router.dart b/client_app/lib/router.dart index 2f30d92..a016996 100644 --- a/client_app/lib/router.dart +++ b/client_app/lib/router.dart @@ -1,7 +1,7 @@ -import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'core/auth/auth_bloc.dart'; +import 'core/auth/auth_notifier.dart'; import 'features/auth/screens/welcome_screen.dart'; import 'features/auth/screens/display_name_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_transcript_screen.dart'; -/// Converts a BLoC stream into a ChangeNotifier for GoRouter's refreshListenable. -class _BlocRefreshNotifier extends ChangeNotifier { - late final StreamSubscription _subscription; +class RouterNotifier extends ChangeNotifier { + final Ref _ref; - _BlocRefreshNotifier(AuthBloc bloc) { - _subscription = bloc.stream.listen((_) => notifyListeners()); - } - - @override - void dispose() { - _subscription.cancel(); - super.dispose(); + RouterNotifier(this._ref) { + _ref.listen(authProvider, (_, __) => notifyListeners()); } } -GoRouter buildRouter(AuthBloc authBloc) { +final routerProvider = Provider((ref) => buildRouter(ref)); + +GoRouter buildRouter(Ref ref) { + final notifier = RouterNotifier(ref); + return GoRouter( initialLocation: '/splash', - refreshListenable: _BlocRefreshNotifier(authBloc), + refreshListenable: notifier, redirect: (context, state) { - final authState = authBloc.state; + final authState = ref.read(authProvider); final isSplash = state.matchedLocation == '/splash'; final isAuthRoute = state.matchedLocation.startsWith('/auth') || state.matchedLocation == '/welcome'; // 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; } - if (authState is AuthForceRegister) return '/auth/force-register'; + if (data is AuthForceRegisterData) return '/auth/force-register'; if (!isAuthRoute && !isSplash) return '/welcome'; if (isSplash) return '/welcome'; return null; diff --git a/client_app/pubspec.lock b/client_app/pubspec.lock index 27ce694..a8725b8 100644 --- a/client_app/pubspec.lock +++ b/client_app/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile 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: dependency: transitive description: @@ -9,6 +17,22 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -41,6 +65,70 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -49,6 +137,30 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -57,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -65,6 +185,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" crypto: dependency: transitive description: @@ -73,6 +201,46 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -201,6 +369,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.8.7" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -214,6 +390,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct dev" description: @@ -254,6 +438,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct dev" description: flutter @@ -264,6 +456,30 @@ packages: description: flutter source: sdk 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: dependency: "direct main" description: @@ -320,6 +536,30 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -328,6 +568,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -336,6 +584,30 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -416,6 +688,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -472,6 +752,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" provider: dependency: transitive description: @@ -480,6 +768,70 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -536,6 +888,22 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -565,6 +933,14 @@ packages: description: flutter source: sdk 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: dependency: transitive description: @@ -581,6 +957,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -589,6 +973,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -621,6 +1013,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.11.0" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" typed_data: dependency: transitive description: @@ -629,6 +1029,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" vector_math: dependency: transitive description: @@ -645,6 +1053,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" web: dependency: transitive description: @@ -677,6 +1093,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: dart: ">=3.10.0 <4.0.0" flutter: ">=3.38.1" diff --git a/client_app/pubspec.yaml b/client_app/pubspec.yaml index b0c1704..68b5903 100644 --- a/client_app/pubspec.yaml +++ b/client_app/pubspec.yaml @@ -27,6 +27,10 @@ dependencies: # State management flutter_bloc: ^8.1.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 shared_preferences: ^2.2.3 @@ -39,6 +43,10 @@ dev_dependencies: flutter_test: sdk: flutter 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: uses-material-design: true diff --git a/mitra_app/android/app/src/main/AndroidManifest.xml b/mitra_app/android/app/src/main/AndroidManifest.xml index feab6ed..89ec289 100644 --- a/mitra_app/android/app/src/main/AndroidManifest.xml +++ b/mitra_app/android/app/src/main/AndroidManifest.xml @@ -2,7 +2,8 @@ + android:icon="@mipmap/ic_launcher" + android:usesCleartextTraffic="true"> ApiClient(); diff --git a/mitra_app/lib/core/api/api_client_provider.g.dart b/mitra_app/lib/core/api/api_client_provider.g.dart new file mode 100644 index 0000000..c46826e --- /dev/null +++ b/mitra_app/lib/core/api/api_client_provider.g.dart @@ -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.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; +// 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 diff --git a/mitra_app/lib/main.dart b/mitra_app/lib/main.dart index 5e5e246..1c660f0 100644 --- a/mitra_app/lib/main.dart +++ b/mitra_app/lib/main.dart @@ -2,6 +2,7 @@ import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.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 'core/api/api_client.dart'; import 'core/auth/auth_bloc.dart'; @@ -20,7 +21,7 @@ void main() async { final messaging = FirebaseMessaging.instance; await messaging.requestPermission(); - runApp(const App()); + runApp(const ProviderScope(child: App())); } class App extends StatefulWidget { diff --git a/mitra_app/pubspec.lock b/mitra_app/pubspec.lock index 2c47bfa..6024c37 100644 --- a/mitra_app/pubspec.lock +++ b/mitra_app/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile 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: dependency: transitive description: @@ -9,6 +17,22 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -41,6 +65,70 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -49,6 +137,30 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -57,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -65,6 +185,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" crypto: dependency: transitive description: @@ -73,6 +201,46 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -121,6 +289,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" firebase_auth: dependency: "direct main" description: @@ -193,6 +369,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.8.7" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -206,6 +390,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct dev" description: @@ -246,6 +438,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct dev" description: flutter @@ -256,6 +456,30 @@ packages: description: flutter source: sdk 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: dependency: "direct main" description: @@ -264,6 +488,30 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -272,6 +520,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -280,6 +536,30 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -360,6 +640,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -384,6 +672,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" provider: dependency: transitive description: @@ -392,11 +688,99 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: flutter source: sdk 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: dependency: transitive description: @@ -413,6 +797,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -421,6 +813,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -453,6 +853,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.11.0" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" typed_data: dependency: transitive description: @@ -461,6 +869,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" vector_math: dependency: transitive description: @@ -477,6 +893,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" web: dependency: transitive description: @@ -509,6 +933,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: dart: ">=3.10.0 <4.0.0" flutter: ">=3.38.1" diff --git a/mitra_app/pubspec.yaml b/mitra_app/pubspec.yaml index 4636871..9f34e3e 100644 --- a/mitra_app/pubspec.yaml +++ b/mitra_app/pubspec.yaml @@ -23,6 +23,10 @@ dependencies: # State management flutter_bloc: ^8.1.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 go_router: ^13.2.1 @@ -32,6 +36,10 @@ dev_dependencies: flutter_test: sdk: flutter 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: uses-material-design: true diff --git a/requirement/phase3.1-plan.md b/requirement/phase3.1-plan.md new file mode 100644 index 0000000..e93481a --- /dev/null +++ b/requirement/phase3.1-plan.md @@ -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` 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` 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 (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 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` → `ConsumerWidget` + `ref.watch(authProvider)` +- `BlocListener` → `ref.listen(authProvider, ...)` +- `context.read().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 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 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 — { 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 3–8 | +| 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 10–15 | +| 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 27–28 | +| 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 21–26 | +| 34 | E2E test: closure FCM fallback + unread badges | All | Steps 27–32 | + +--- + +## 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 | diff --git a/requirement/phase3.1.md b/requirement/phase3.1.md new file mode 100644 index 0000000..ac810da --- /dev/null +++ b/requirement/phase3.1.md @@ -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