Phase 3.1 WIP: Riverpod migration (client_app Auth + ChatOpening)
- Add phase3.1 requirement and implementation plan docs - Add Riverpod dependencies to both client_app and mitra_app - Wrap both app roots with ProviderScope - Migrate client_app AuthBloc → AuthNotifier (@riverpod annotation) - Migrate client_app ChatOpeningBloc → chatPricingProvider (FutureProvider) - Update router to use Riverpod-based auth state for redirects - Update all auth screens (display name, register, OTP, force register) - Update home screen and pricing bottom sheet - Add android:usesCleartextTraffic for dev HTTP access on both apps - mitra_app prepared with ProviderScope + ApiClient provider (blocs next) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,8 @@
|
|||||||
<application
|
<application
|
||||||
android:label="client_app"
|
android:label="client_app"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
8
client_app/lib/core/api/api_client_provider.dart
Normal file
8
client_app/lib/core/api/api_client_provider.dart
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'api_client.dart';
|
||||||
|
|
||||||
|
part 'api_client_provider.g.dart';
|
||||||
|
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
ApiClient apiClient(Ref ref) => ApiClient();
|
||||||
26
client_app/lib/core/api/api_client_provider.g.dart
Normal file
26
client_app/lib/core/api/api_client_provider.g.dart
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'api_client_provider.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
String _$apiClientHash() => r'90c807f03b90249684265cc91739139c2c89eeb9';
|
||||||
|
|
||||||
|
/// See also [apiClient].
|
||||||
|
@ProviderFor(apiClient)
|
||||||
|
final apiClientProvider = Provider<ApiClient>.internal(
|
||||||
|
apiClient,
|
||||||
|
name: r'apiClientProvider',
|
||||||
|
debugGetCreateSourceHash:
|
||||||
|
const bool.fromEnvironment('dart.vm.product') ? null : _$apiClientHash,
|
||||||
|
dependencies: null,
|
||||||
|
allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||||
|
// ignore: unused_element
|
||||||
|
typedef ApiClientRef = ProviderRef<ApiClient>;
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||||
198
client_app/lib/core/auth/auth_notifier.dart
Normal file
198
client_app/lib/core/auth/auth_notifier.dart
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import 'package:google_sign_in/google_sign_in.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
|
||||||
|
import '../api/api_client.dart';
|
||||||
|
import '../api/api_client_provider.dart';
|
||||||
|
|
||||||
|
part 'auth_notifier.g.dart';
|
||||||
|
|
||||||
|
// States
|
||||||
|
sealed class AuthData {
|
||||||
|
const AuthData();
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthInitialData extends AuthData {
|
||||||
|
const AuthInitialData();
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthAuthenticatedData extends AuthData {
|
||||||
|
final Map<String, dynamic> profile;
|
||||||
|
const AuthAuthenticatedData(this.profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthAnonymousData extends AuthData {
|
||||||
|
final String customerId;
|
||||||
|
final String displayName;
|
||||||
|
const AuthAnonymousData({required this.customerId, required this.displayName});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthOtpSentData extends AuthData {
|
||||||
|
final String verificationId;
|
||||||
|
const AuthOtpSentData(this.verificationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthForceRegisterData extends AuthData {
|
||||||
|
final String customerId;
|
||||||
|
final String displayName;
|
||||||
|
const AuthForceRegisterData({required this.customerId, required this.displayName});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class Auth extends _$Auth {
|
||||||
|
FirebaseAuth get _auth => FirebaseAuth.instance;
|
||||||
|
ApiClient get _apiClient => ref.read(apiClientProvider);
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<AuthData> build() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final customerId = prefs.getString('anonymous_customer_id');
|
||||||
|
final displayName = prefs.getString('anonymous_display_name');
|
||||||
|
final currentUser = _auth.currentUser;
|
||||||
|
|
||||||
|
if (currentUser != null && currentUser.isAnonymous && customerId != null && displayName != null) {
|
||||||
|
try {
|
||||||
|
final config = await _apiClient.get('/api/shared/config/anonymity');
|
||||||
|
final anonymityEnabled = config['data']['anonymity_enabled'] as bool;
|
||||||
|
if (!anonymityEnabled) {
|
||||||
|
return AuthForceRegisterData(customerId: customerId, displayName: displayName);
|
||||||
|
}
|
||||||
|
return AuthAnonymousData(customerId: customerId, displayName: displayName);
|
||||||
|
} catch (_) {
|
||||||
|
return AuthAnonymousData(customerId: customerId, displayName: displayName);
|
||||||
|
}
|
||||||
|
} else if (currentUser != null && !currentUser.isAnonymous) {
|
||||||
|
return await _verifyAndReturn();
|
||||||
|
}
|
||||||
|
return const AuthInitialData();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loginAnonymous(String displayName) async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
try {
|
||||||
|
await _auth.signInAnonymously();
|
||||||
|
final response = await _apiClient.post(
|
||||||
|
'/api/shared/customer/anonymous',
|
||||||
|
data: {'display_name': displayName},
|
||||||
|
);
|
||||||
|
final customer = response['data'] as Map<String, dynamic>;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString('anonymous_customer_id', customer['id'] as String);
|
||||||
|
await prefs.setString('anonymous_display_name', customer['display_name'] as String);
|
||||||
|
state = AsyncData(AuthAnonymousData(
|
||||||
|
customerId: customer['id'] as String,
|
||||||
|
displayName: customer['display_name'] as String,
|
||||||
|
));
|
||||||
|
} catch (e) {
|
||||||
|
state = AsyncError('Failed to continue as guest. Please try again.', StackTrace.current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loginGoogle() async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
try {
|
||||||
|
final googleUser = await GoogleSignIn().signIn();
|
||||||
|
if (googleUser == null) {
|
||||||
|
state = const AsyncData(AuthInitialData());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final googleAuth = await googleUser.authentication;
|
||||||
|
final credential = GoogleAuthProvider.credential(
|
||||||
|
accessToken: googleAuth.accessToken,
|
||||||
|
idToken: googleAuth.idToken,
|
||||||
|
);
|
||||||
|
await _auth.signInWithCredential(credential);
|
||||||
|
state = AsyncData(await _verifyAndReturn());
|
||||||
|
} catch (e) {
|
||||||
|
state = AsyncError('Google sign-in failed. Please try again.', StackTrace.current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loginApple() async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
try {
|
||||||
|
final appleCredential = await SignInWithApple.getAppleIDCredential(
|
||||||
|
scopes: [AppleIDAuthorizationScopes.email],
|
||||||
|
);
|
||||||
|
final oauthCredential = OAuthProvider('apple.com').credential(
|
||||||
|
idToken: appleCredential.identityToken,
|
||||||
|
accessToken: appleCredential.authorizationCode,
|
||||||
|
);
|
||||||
|
await _auth.signInWithCredential(oauthCredential);
|
||||||
|
state = AsyncData(await _verifyAndReturn());
|
||||||
|
} catch (e) {
|
||||||
|
state = AsyncError('Apple sign-in failed. Please try again.', StackTrace.current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> requestOtp(String phone) async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
final completer = Completer<void>();
|
||||||
|
await _auth.verifyPhoneNumber(
|
||||||
|
phoneNumber: phone,
|
||||||
|
verificationCompleted: (_) {
|
||||||
|
if (!completer.isCompleted) completer.complete();
|
||||||
|
},
|
||||||
|
verificationFailed: (e) {
|
||||||
|
state = AsyncError('Failed to send OTP. Please try again.', StackTrace.current);
|
||||||
|
if (!completer.isCompleted) completer.complete();
|
||||||
|
},
|
||||||
|
codeSent: (verificationId, _) {
|
||||||
|
state = AsyncData(AuthOtpSentData(verificationId));
|
||||||
|
if (!completer.isCompleted) completer.complete();
|
||||||
|
},
|
||||||
|
codeAutoRetrievalTimeout: (_) {
|
||||||
|
if (!completer.isCompleted) completer.complete();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await completer.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> verifyOtp(String verificationId, String smsCode) async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
try {
|
||||||
|
final credential = PhoneAuthProvider.credential(
|
||||||
|
verificationId: verificationId,
|
||||||
|
smsCode: smsCode,
|
||||||
|
);
|
||||||
|
await _auth.signInWithCredential(credential);
|
||||||
|
state = AsyncData(await _verifyAndReturn());
|
||||||
|
} catch (e) {
|
||||||
|
state = AsyncError('Invalid OTP. Please try again.', StackTrace.current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> linkAccount() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final customerId = prefs.getString('anonymous_customer_id');
|
||||||
|
if (customerId == null || _auth.currentUser == null) return;
|
||||||
|
|
||||||
|
state = const AsyncLoading();
|
||||||
|
try {
|
||||||
|
await _apiClient.post('/api/shared/customer/link', data: {
|
||||||
|
'customer_id': customerId,
|
||||||
|
'firebase_uid': _auth.currentUser!.uid,
|
||||||
|
});
|
||||||
|
await prefs.remove('anonymous_customer_id');
|
||||||
|
await prefs.remove('anonymous_display_name');
|
||||||
|
state = AsyncData(await _verifyAndReturn());
|
||||||
|
} catch (e) {
|
||||||
|
state = AsyncError('Failed to link account. Please try again.', StackTrace.current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> logout() async {
|
||||||
|
await _auth.signOut();
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove('anonymous_customer_id');
|
||||||
|
await prefs.remove('anonymous_display_name');
|
||||||
|
state = const AsyncData(AuthInitialData());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AuthData> _verifyAndReturn() async {
|
||||||
|
final response = await _apiClient.post('/api/client/auth/verify');
|
||||||
|
return AuthAuthenticatedData(response['data'] as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
client_app/lib/core/auth/auth_notifier.g.dart
Normal file
24
client_app/lib/core/auth/auth_notifier.g.dart
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'auth_notifier.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
String _$authHash() => r'8cb877e94ccf4366b574ffe8c8b4b63321340b6d';
|
||||||
|
|
||||||
|
/// See also [Auth].
|
||||||
|
@ProviderFor(Auth)
|
||||||
|
final authProvider = AsyncNotifierProvider<Auth, AuthData>.internal(
|
||||||
|
Auth.new,
|
||||||
|
name: r'authProvider',
|
||||||
|
debugGetCreateSourceHash:
|
||||||
|
const bool.fromEnvironment('dart.vm.product') ? null : _$authHash,
|
||||||
|
dependencies: null,
|
||||||
|
allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
typedef _$Auth = AsyncNotifier<AuthData>;
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||||
49
client_app/lib/core/chat/chat_opening_provider.dart
Normal file
49
client_app/lib/core/chat/chat_opening_provider.dart
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import '../api/api_client_provider.dart';
|
||||||
|
|
||||||
|
part 'chat_opening_provider.g.dart';
|
||||||
|
|
||||||
|
class PriceTier {
|
||||||
|
final int durationMinutes;
|
||||||
|
final int price;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
PriceTier({required this.durationMinutes, required this.price, required this.label});
|
||||||
|
|
||||||
|
factory PriceTier.fromJson(Map<String, dynamic> json) {
|
||||||
|
return PriceTier(
|
||||||
|
durationMinutes: json['duration_minutes'] as int,
|
||||||
|
price: json['price'] as int,
|
||||||
|
label: json['label'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PricingData {
|
||||||
|
final List<PriceTier> tiers;
|
||||||
|
final bool freeTrialEligible;
|
||||||
|
final int freeTrialDurationMinutes;
|
||||||
|
|
||||||
|
const PricingData({
|
||||||
|
required this.tiers,
|
||||||
|
required this.freeTrialEligible,
|
||||||
|
this.freeTrialDurationMinutes = 5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
Future<PricingData> chatPricing(Ref ref) async {
|
||||||
|
final apiClient = ref.read(apiClientProvider);
|
||||||
|
final response = await apiClient.get('/api/client/chat/pricing');
|
||||||
|
final data = response['data'] as Map<String, dynamic>;
|
||||||
|
final tiersJson = data['tiers'] as List<dynamic>;
|
||||||
|
final tiers = tiersJson.map((t) => PriceTier.fromJson(t as Map<String, dynamic>)).toList();
|
||||||
|
final freeTrial = data['free_trial'] as Map<String, dynamic>;
|
||||||
|
|
||||||
|
return PricingData(
|
||||||
|
tiers: tiers,
|
||||||
|
freeTrialEligible: freeTrial['eligible'] as bool? ?? false,
|
||||||
|
freeTrialDurationMinutes: freeTrial['duration_minutes'] as int? ?? 5,
|
||||||
|
);
|
||||||
|
}
|
||||||
26
client_app/lib/core/chat/chat_opening_provider.g.dart
Normal file
26
client_app/lib/core/chat/chat_opening_provider.g.dart
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'chat_opening_provider.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
String _$chatPricingHash() => r'53ca829d46f7ba3b481e7a6b54482c62f9fe3ad0';
|
||||||
|
|
||||||
|
/// See also [chatPricing].
|
||||||
|
@ProviderFor(chatPricing)
|
||||||
|
final chatPricingProvider = AutoDisposeFutureProvider<PricingData>.internal(
|
||||||
|
chatPricing,
|
||||||
|
name: r'chatPricingProvider',
|
||||||
|
debugGetCreateSourceHash:
|
||||||
|
const bool.fromEnvironment('dart.vm.product') ? null : _$chatPricingHash,
|
||||||
|
dependencies: null,
|
||||||
|
allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||||
|
// ignore: unused_element
|
||||||
|
typedef ChatPricingRef = AutoDisposeFutureProviderRef<PricingData>;
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/auth/auth_bloc.dart';
|
import '../../../core/auth/auth_notifier.dart';
|
||||||
|
|
||||||
class DisplayNameScreen extends StatefulWidget {
|
class DisplayNameScreen extends ConsumerStatefulWidget {
|
||||||
const DisplayNameScreen({super.key});
|
const DisplayNameScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<DisplayNameScreen> createState() => _DisplayNameScreenState();
|
ConsumerState<DisplayNameScreen> createState() => _DisplayNameScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DisplayNameScreenState extends State<DisplayNameScreen> {
|
class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
|
||||||
final _controller = TextEditingController();
|
final _controller = TextEditingController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -21,46 +21,46 @@ class _DisplayNameScreenState extends State<DisplayNameScreen> {
|
|||||||
void _submit() {
|
void _submit() {
|
||||||
final name = _controller.text.trim();
|
final name = _controller.text.trim();
|
||||||
if (name.isEmpty) return;
|
if (name.isEmpty) return;
|
||||||
context.read<AuthBloc>().add(AnonymousLoginRequested(name));
|
ref.read(authProvider.notifier).loginAnonymous(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocListener<AuthBloc, AuthState>(
|
final authState = ref.watch(authProvider);
|
||||||
listener: (context, state) {
|
final isLoading = authState is AsyncLoading;
|
||||||
if (state is AuthError) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
|
ref.listen(authProvider, (prev, next) {
|
||||||
}
|
if (next is AsyncError) {
|
||||||
},
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
|
||||||
child: Scaffold(
|
}
|
||||||
appBar: AppBar(title: const Text('Siapa namamu?')),
|
});
|
||||||
body: Padding(
|
|
||||||
padding: const EdgeInsets.all(24),
|
return Scaffold(
|
||||||
child: Column(
|
appBar: AppBar(title: const Text('Siapa namamu?')),
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
body: Padding(
|
||||||
children: [
|
padding: const EdgeInsets.all(24),
|
||||||
const Text('Pilih nama yang ingin kamu gunakan. Nama ini tidak akan terlihat oleh siapapun selain mitra kamu.'),
|
child: Column(
|
||||||
const SizedBox(height: 24),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
TextField(
|
children: [
|
||||||
controller: _controller,
|
const Text('Pilih nama yang ingin kamu gunakan. Nama ini tidak akan terlihat oleh siapapun selain mitra kamu.'),
|
||||||
decoration: const InputDecoration(
|
const SizedBox(height: 24),
|
||||||
labelText: 'Nama panggilan',
|
TextField(
|
||||||
border: OutlineInputBorder(),
|
controller: _controller,
|
||||||
),
|
decoration: const InputDecoration(
|
||||||
textInputAction: TextInputAction.done,
|
labelText: 'Nama panggilan',
|
||||||
onSubmitted: (_) => _submit(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
textInputAction: TextInputAction.done,
|
||||||
BlocBuilder<AuthBloc, AuthState>(
|
onSubmitted: (_) => _submit(),
|
||||||
builder: (context, state) => ElevatedButton(
|
),
|
||||||
onPressed: state is AuthLoading ? null : _submit,
|
const SizedBox(height: 24),
|
||||||
child: state is AuthLoading
|
ElevatedButton(
|
||||||
? const CircularProgressIndicator()
|
onPressed: isLoading ? null : _submit,
|
||||||
: const Text('Lanjut'),
|
child: isLoading
|
||||||
),
|
? const CircularProgressIndicator()
|
||||||
),
|
: const Text('Lanjut'),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../core/auth/auth_bloc.dart';
|
import '../../../core/auth/auth_notifier.dart';
|
||||||
|
|
||||||
/// Shown when anonymity is disabled by admin.
|
/// Shown when anonymity is disabled by admin.
|
||||||
/// User must link their account. Display name is pre-filled.
|
/// User must link their account. Display name is pre-filled.
|
||||||
class ForceRegisterScreen extends StatefulWidget {
|
class ForceRegisterScreen extends ConsumerStatefulWidget {
|
||||||
const ForceRegisterScreen({super.key});
|
const ForceRegisterScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ForceRegisterScreen> createState() => _ForceRegisterScreenState();
|
ConsumerState<ForceRegisterScreen> createState() => _ForceRegisterScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ForceRegisterScreenState extends State<ForceRegisterScreen> {
|
class _ForceRegisterScreenState extends ConsumerState<ForceRegisterScreen> {
|
||||||
final _phoneController = TextEditingController();
|
final _phoneController = TextEditingController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -23,80 +23,77 @@ class _ForceRegisterScreenState extends State<ForceRegisterScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocListener<AuthBloc, AuthState>(
|
final authState = ref.watch(authProvider);
|
||||||
listener: (context, state) {
|
final isLoading = authState is AsyncLoading;
|
||||||
if (state is AuthOtpSent) {
|
|
||||||
context.push('/auth/otp', extra: _phoneController.text.trim());
|
ref.listen(authProvider, (prev, next) {
|
||||||
}
|
final data = next.valueOrNull;
|
||||||
if (state is AuthAuthenticated) {
|
if (data is AuthOtpSentData) {
|
||||||
// After linking, link account to existing anonymous record
|
context.push('/auth/otp', extra: _phoneController.text.trim());
|
||||||
context.read<AuthBloc>().add(LinkAccountRequested());
|
}
|
||||||
}
|
if (data is AuthAuthenticatedData) {
|
||||||
if (state is AuthError) {
|
// After social login succeeds, link account to existing anonymous record
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
|
ref.read(authProvider.notifier).linkAccount();
|
||||||
}
|
}
|
||||||
},
|
if (next is AsyncError) {
|
||||||
child: Scaffold(
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
|
||||||
appBar: AppBar(title: const Text('Verifikasi Akun')),
|
}
|
||||||
body: Padding(
|
});
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Column(
|
return Scaffold(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
appBar: AppBar(title: const Text('Verifikasi Akun')),
|
||||||
children: [
|
body: Padding(
|
||||||
const Text(
|
padding: const EdgeInsets.all(24),
|
||||||
'Untuk melanjutkan, kamu perlu mendaftarkan akun.',
|
child: Column(
|
||||||
style: TextStyle(fontSize: 16),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Untuk melanjutkan, kamu perlu mendaftarkan akun.',
|
||||||
|
style: TextStyle(fontSize: 16),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.g_mobiledata),
|
||||||
|
onPressed: isLoading ? null
|
||||||
|
: () => ref.read(authProvider.notifier).loginGoogle(),
|
||||||
|
label: const Text('Lanjut dengan Google'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.apple),
|
||||||
|
onPressed: isLoading ? null
|
||||||
|
: () => ref.read(authProvider.notifier).loginApple(),
|
||||||
|
label: const Text('Lanjut dengan Apple'),
|
||||||
|
),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 24),
|
||||||
|
child: Row(children: [
|
||||||
|
Expanded(child: Divider()),
|
||||||
|
Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')),
|
||||||
|
Expanded(child: Divider()),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: _phoneController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Nomor HP',
|
||||||
|
hintText: '+628xxxxxxxxxx',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
keyboardType: TextInputType.phone,
|
||||||
BlocBuilder<AuthBloc, AuthState>(
|
),
|
||||||
builder: (context, state) => ElevatedButton.icon(
|
const SizedBox(height: 12),
|
||||||
icon: const Icon(Icons.g_mobiledata),
|
ElevatedButton(
|
||||||
onPressed: state is AuthLoading ? null
|
onPressed: isLoading ? null : () {
|
||||||
: () => context.read<AuthBloc>().add(GoogleLoginRequested()),
|
final phone = _phoneController.text.trim();
|
||||||
label: const Text('Lanjut dengan Google'),
|
if (phone.isEmpty) return;
|
||||||
),
|
ref.read(authProvider.notifier).requestOtp(phone);
|
||||||
),
|
},
|
||||||
const SizedBox(height: 12),
|
child: isLoading
|
||||||
BlocBuilder<AuthBloc, AuthState>(
|
? const CircularProgressIndicator()
|
||||||
builder: (context, state) => ElevatedButton.icon(
|
: const Text('Kirim OTP'),
|
||||||
icon: const Icon(Icons.apple),
|
),
|
||||||
onPressed: state is AuthLoading ? null
|
],
|
||||||
: () => context.read<AuthBloc>().add(AppleLoginRequested()),
|
|
||||||
label: const Text('Lanjut dengan Apple'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(vertical: 24),
|
|
||||||
child: Row(children: [
|
|
||||||
Expanded(child: Divider()),
|
|
||||||
Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')),
|
|
||||||
Expanded(child: Divider()),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
TextField(
|
|
||||||
controller: _phoneController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Nomor HP',
|
|
||||||
hintText: '+628xxxxxxxxxx',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
keyboardType: TextInputType.phone,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
BlocBuilder<AuthBloc, AuthState>(
|
|
||||||
builder: (context, state) => ElevatedButton(
|
|
||||||
onPressed: state is AuthLoading ? null : () {
|
|
||||||
final phone = _phoneController.text.trim();
|
|
||||||
if (phone.isEmpty) return;
|
|
||||||
context.read<AuthBloc>().add(PhoneOtpRequested(phone));
|
|
||||||
},
|
|
||||||
child: state is AuthLoading
|
|
||||||
? const CircularProgressIndicator()
|
|
||||||
: const Text('Kirim OTP'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/auth/auth_bloc.dart';
|
import '../../../core/auth/auth_notifier.dart';
|
||||||
|
|
||||||
class OtpScreen extends StatefulWidget {
|
class OtpScreen extends ConsumerStatefulWidget {
|
||||||
final String phone;
|
final String phone;
|
||||||
const OtpScreen({super.key, required this.phone});
|
const OtpScreen({super.key, required this.phone});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<OtpScreen> createState() => _OtpScreenState();
|
ConsumerState<OtpScreen> createState() => _OtpScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _OtpScreenState extends State<OtpScreen> {
|
class _OtpScreenState extends ConsumerState<OtpScreen> {
|
||||||
final _otpController = TextEditingController();
|
final _otpController = TextEditingController();
|
||||||
|
String? _verificationId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Capture verification ID from current state
|
||||||
|
final data = ref.read(authProvider).valueOrNull;
|
||||||
|
if (data is AuthOtpSentData) {
|
||||||
|
_verificationId = data.verificationId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -21,46 +32,51 @@ class _OtpScreenState extends State<OtpScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocListener<AuthBloc, AuthState>(
|
final authState = ref.watch(authProvider);
|
||||||
listener: (context, state) {
|
final isLoading = authState is AsyncLoading;
|
||||||
if (state is AuthError) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
|
// Update verification ID if state changes
|
||||||
}
|
final data = authState.valueOrNull;
|
||||||
},
|
if (data is AuthOtpSentData) {
|
||||||
child: Scaffold(
|
_verificationId = data.verificationId;
|
||||||
appBar: AppBar(title: const Text('Masukkan OTP')),
|
}
|
||||||
body: Padding(
|
|
||||||
padding: const EdgeInsets.all(24),
|
ref.listen(authProvider, (prev, next) {
|
||||||
child: Column(
|
if (next is AsyncError) {
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
|
||||||
children: [
|
}
|
||||||
Text('Kode OTP telah dikirim ke ${widget.phone}'),
|
});
|
||||||
const SizedBox(height: 24),
|
|
||||||
TextField(
|
return Scaffold(
|
||||||
controller: _otpController,
|
appBar: AppBar(title: const Text('Masukkan OTP')),
|
||||||
decoration: const InputDecoration(
|
body: Padding(
|
||||||
labelText: 'Kode OTP',
|
padding: const EdgeInsets.all(24),
|
||||||
border: OutlineInputBorder(),
|
child: Column(
|
||||||
),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
keyboardType: TextInputType.number,
|
children: [
|
||||||
maxLength: 6,
|
Text('Kode OTP telah dikirim ke ${widget.phone}'),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
TextField(
|
||||||
|
controller: _otpController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Kode OTP',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
keyboardType: TextInputType.number,
|
||||||
BlocBuilder<AuthBloc, AuthState>(
|
maxLength: 6,
|
||||||
builder: (context, state) => ElevatedButton(
|
),
|
||||||
onPressed: state is AuthLoading ? null : () {
|
const SizedBox(height: 12),
|
||||||
final otp = _otpController.text.trim();
|
ElevatedButton(
|
||||||
if (otp.length != 6) return;
|
onPressed: isLoading ? null : () {
|
||||||
final verificationId = (state is AuthOtpSent) ? state.verificationId : '';
|
final otp = _otpController.text.trim();
|
||||||
context.read<AuthBloc>().add(OtpVerified(verificationId, otp));
|
if (otp.length != 6 || _verificationId == null) return;
|
||||||
},
|
ref.read(authProvider.notifier).verifyOtp(_verificationId!, otp);
|
||||||
child: state is AuthLoading
|
},
|
||||||
? const CircularProgressIndicator()
|
child: isLoading
|
||||||
: const Text('Verifikasi'),
|
? const CircularProgressIndicator()
|
||||||
),
|
: const Text('Verifikasi'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../core/auth/auth_bloc.dart';
|
import '../../../core/auth/auth_notifier.dart';
|
||||||
|
|
||||||
class RegisterScreen extends StatefulWidget {
|
class RegisterScreen extends ConsumerStatefulWidget {
|
||||||
const RegisterScreen({super.key});
|
const RegisterScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<RegisterScreen> createState() => _RegisterScreenState();
|
ConsumerState<RegisterScreen> createState() => _RegisterScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RegisterScreenState extends State<RegisterScreen> {
|
class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||||
final _phoneController = TextEditingController();
|
final _phoneController = TextEditingController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -21,71 +21,68 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocListener<AuthBloc, AuthState>(
|
final authState = ref.watch(authProvider);
|
||||||
listener: (context, state) {
|
final isLoading = authState is AsyncLoading;
|
||||||
if (state is AuthOtpSent) {
|
|
||||||
context.push('/auth/otp', extra: _phoneController.text.trim());
|
ref.listen(authProvider, (prev, next) {
|
||||||
}
|
final data = next.valueOrNull;
|
||||||
if (state is AuthError) {
|
if (data is AuthOtpSentData) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
|
context.push('/auth/otp', extra: _phoneController.text.trim());
|
||||||
}
|
}
|
||||||
},
|
if (next is AsyncError) {
|
||||||
child: Scaffold(
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
|
||||||
appBar: AppBar(title: const Text('Masuk / Daftar')),
|
}
|
||||||
body: Padding(
|
});
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Column(
|
return Scaffold(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
appBar: AppBar(title: const Text('Masuk / Daftar')),
|
||||||
children: [
|
body: Padding(
|
||||||
BlocBuilder<AuthBloc, AuthState>(
|
padding: const EdgeInsets.all(24),
|
||||||
builder: (context, state) => ElevatedButton.icon(
|
child: Column(
|
||||||
icon: const Icon(Icons.g_mobiledata),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
onPressed: state is AuthLoading ? null
|
children: [
|
||||||
: () => context.read<AuthBloc>().add(GoogleLoginRequested()),
|
ElevatedButton.icon(
|
||||||
label: const Text('Lanjut dengan Google'),
|
icon: const Icon(Icons.g_mobiledata),
|
||||||
),
|
onPressed: isLoading ? null
|
||||||
|
: () => ref.read(authProvider.notifier).loginGoogle(),
|
||||||
|
label: const Text('Lanjut dengan Google'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.apple),
|
||||||
|
onPressed: isLoading ? null
|
||||||
|
: () => ref.read(authProvider.notifier).loginApple(),
|
||||||
|
label: const Text('Lanjut dengan Apple'),
|
||||||
|
),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 24),
|
||||||
|
child: Row(children: [
|
||||||
|
Expanded(child: Divider()),
|
||||||
|
Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')),
|
||||||
|
Expanded(child: Divider()),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: _phoneController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Nomor HP',
|
||||||
|
hintText: '+628xxxxxxxxxx',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
keyboardType: TextInputType.phone,
|
||||||
BlocBuilder<AuthBloc, AuthState>(
|
),
|
||||||
builder: (context, state) => ElevatedButton.icon(
|
const SizedBox(height: 12),
|
||||||
icon: const Icon(Icons.apple),
|
ElevatedButton(
|
||||||
onPressed: state is AuthLoading ? null
|
onPressed: isLoading ? null : () {
|
||||||
: () => context.read<AuthBloc>().add(AppleLoginRequested()),
|
final phone = _phoneController.text.trim();
|
||||||
label: const Text('Lanjut dengan Apple'),
|
if (phone.isEmpty) return;
|
||||||
),
|
ref.read(authProvider.notifier).requestOtp(phone);
|
||||||
),
|
},
|
||||||
const Padding(
|
child: isLoading
|
||||||
padding: EdgeInsets.symmetric(vertical: 24),
|
? const CircularProgressIndicator()
|
||||||
child: Row(children: [
|
: const Text('Kirim OTP'),
|
||||||
Expanded(child: Divider()),
|
),
|
||||||
Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')),
|
],
|
||||||
Expanded(child: Divider()),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
TextField(
|
|
||||||
controller: _phoneController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Nomor HP',
|
|
||||||
hintText: '+628xxxxxxxxxx',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
keyboardType: TextInputType.phone,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
BlocBuilder<AuthBloc, AuthState>(
|
|
||||||
builder: (context, state) => ElevatedButton(
|
|
||||||
onPressed: state is AuthLoading ? null : () {
|
|
||||||
final phone = _phoneController.text.trim();
|
|
||||||
if (phone.isEmpty) return;
|
|
||||||
context.read<AuthBloc>().add(PhoneOtpRequested(phone));
|
|
||||||
},
|
|
||||||
child: state is AuthLoading
|
|
||||||
? const CircularProgressIndicator()
|
|
||||||
: const Text('Kirim OTP'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../../../core/api/api_client.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/chat/chat_opening_bloc.dart';
|
import '../../../core/chat/chat_opening_provider.dart';
|
||||||
import '../../../core/chat/session_closure_bloc.dart';
|
import '../../../core/chat/session_closure_bloc.dart';
|
||||||
import '../../../core/pairing/pairing_bloc.dart';
|
import '../../../core/pairing/pairing_bloc.dart';
|
||||||
|
|
||||||
class PricingBottomSheet extends StatelessWidget {
|
class PricingBottomSheet extends ConsumerWidget {
|
||||||
/// If set, the bottom sheet is in "extension" mode — selecting a tier extends the session.
|
/// If set, the bottom sheet is in "extension" mode — selecting a tier extends the session.
|
||||||
final String? extensionSessionId;
|
final String? extensionSessionId;
|
||||||
|
|
||||||
@@ -16,14 +16,11 @@ class PricingBottomSheet extends StatelessWidget {
|
|||||||
return showModalBottomSheet(
|
return showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
builder: (_) => BlocProvider(
|
builder: (_) => MultiBlocProvider(
|
||||||
create: (ctx) => ChatOpeningBloc(apiClient: ctx.read<ApiClient>())..add(LoadPricing()),
|
providers: [
|
||||||
child: MultiBlocProvider(
|
BlocProvider.value(value: context.read<PairingBloc>()),
|
||||||
providers: [
|
],
|
||||||
BlocProvider.value(value: context.read<PairingBloc>()),
|
child: const PricingBottomSheet(),
|
||||||
],
|
|
||||||
child: const PricingBottomSheet(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -33,14 +30,11 @@ class PricingBottomSheet extends StatelessWidget {
|
|||||||
return showModalBottomSheet(
|
return showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
builder: (_) => BlocProvider(
|
builder: (_) => MultiBlocProvider(
|
||||||
create: (ctx) => ChatOpeningBloc(apiClient: ctx.read<ApiClient>())..add(LoadPricing()),
|
providers: [
|
||||||
child: MultiBlocProvider(
|
BlocProvider.value(value: context.read<SessionClosureBloc>()),
|
||||||
providers: [
|
],
|
||||||
BlocProvider.value(value: context.read<SessionClosureBloc>()),
|
child: PricingBottomSheet(extensionSessionId: sessionId),
|
||||||
],
|
|
||||||
child: PricingBottomSheet(extensionSessionId: sessionId),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -56,94 +50,83 @@ class PricingBottomSheet extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final isExtension = extensionSessionId != null;
|
final isExtension = extensionSessionId != null;
|
||||||
|
final pricingAsync = ref.watch(chatPricingProvider);
|
||||||
|
|
||||||
return BlocBuilder<ChatOpeningBloc, ChatOpeningState>(
|
return pricingAsync.when(
|
||||||
builder: (context, state) {
|
loading: () => const SizedBox(
|
||||||
if (state is PricingLoading || state is PricingInitial) {
|
height: 200,
|
||||||
return const SizedBox(
|
child: Center(child: CircularProgressIndicator()),
|
||||||
height: 200,
|
),
|
||||||
child: Center(child: CircularProgressIndicator()),
|
error: (error, _) => SizedBox(
|
||||||
);
|
height: 200,
|
||||||
}
|
child: Center(child: Text('Gagal memuat harga. Coba lagi.')),
|
||||||
|
),
|
||||||
if (state is PricingError) {
|
data: (pricing) => DraggableScrollableSheet(
|
||||||
return SizedBox(
|
initialChildSize: 0.6,
|
||||||
height: 200,
|
minChildSize: 0.4,
|
||||||
child: Center(child: Text(state.message)),
|
maxChildSize: 0.8,
|
||||||
);
|
expand: false,
|
||||||
}
|
builder: (_, scrollController) {
|
||||||
|
return Padding(
|
||||||
if (state is PricingLoaded) {
|
padding: const EdgeInsets.all(24),
|
||||||
return DraggableScrollableSheet(
|
child: ListView(
|
||||||
initialChildSize: 0.6,
|
controller: scrollController,
|
||||||
minChildSize: 0.4,
|
children: [
|
||||||
maxChildSize: 0.8,
|
Text(
|
||||||
expand: false,
|
isExtension ? 'Perpanjang Durasi' : 'Pilih Durasi Curhat',
|
||||||
builder: (_, scrollController) {
|
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||||
return Padding(
|
textAlign: TextAlign.center,
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: ListView(
|
|
||||||
controller: scrollController,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
isExtension ? 'Perpanjang Durasi' : 'Pilih Durasi Curhat',
|
|
||||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
if (!isExtension && state.freeTrialEligible) ...[
|
|
||||||
Card(
|
|
||||||
color: Colors.green.shade50,
|
|
||||||
child: ListTile(
|
|
||||||
leading: const Icon(Icons.card_giftcard, color: Colors.green),
|
|
||||||
title: Text('Free Trial (${state.freeTrialDurationMinutes} Menit)'),
|
|
||||||
subtitle: const Text('Gratis untuk pertama kali!'),
|
|
||||||
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
_startPairing(context, isFreeTrial: true);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(height: 24),
|
|
||||||
],
|
|
||||||
...state.tiers.map((tier) => Card(
|
|
||||||
child: ListTile(
|
|
||||||
title: Text(tier.label),
|
|
||||||
trailing: Text(
|
|
||||||
_formatPrice(tier.price),
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
if (isExtension) {
|
|
||||||
_requestExtension(
|
|
||||||
context,
|
|
||||||
sessionId: extensionSessionId!,
|
|
||||||
durationMinutes: tier.durationMinutes,
|
|
||||||
price: tier.price,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
_startPairing(
|
|
||||||
context,
|
|
||||||
durationMinutes: tier.durationMinutes,
|
|
||||||
price: tier.price,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
const SizedBox(height: 16),
|
||||||
},
|
if (!isExtension && pricing.freeTrialEligible) ...[
|
||||||
|
Card(
|
||||||
|
color: Colors.green.shade50,
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Icons.card_giftcard, color: Colors.green),
|
||||||
|
title: Text('Free Trial (${pricing.freeTrialDurationMinutes} Menit)'),
|
||||||
|
subtitle: const Text('Gratis untuk pertama kali!'),
|
||||||
|
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
_startPairing(context, isFreeTrial: true);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 24),
|
||||||
|
],
|
||||||
|
...pricing.tiers.map((tier) => Card(
|
||||||
|
child: ListTile(
|
||||||
|
title: Text(tier.label),
|
||||||
|
trailing: Text(
|
||||||
|
_formatPrice(tier.price),
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
if (isExtension) {
|
||||||
|
_requestExtension(
|
||||||
|
context,
|
||||||
|
sessionId: extensionSessionId!,
|
||||||
|
durationMinutes: tier.durationMinutes,
|
||||||
|
price: tier.price,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_startPairing(
|
||||||
|
context,
|
||||||
|
durationMinutes: tier.durationMinutes,
|
||||||
|
price: tier.price,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
),
|
||||||
return const SizedBox.shrink();
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../core/auth/auth_bloc.dart';
|
import '../../core/auth/auth_notifier.dart';
|
||||||
import '../../core/api/api_client.dart';
|
import '../../core/api/api_client_provider.dart';
|
||||||
import '../../core/pairing/pairing_bloc.dart';
|
import '../../core/pairing/pairing_bloc.dart';
|
||||||
import '../chat/widgets/pricing_bottom_sheet.dart';
|
import '../chat/widgets/pricing_bottom_sheet.dart';
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends ConsumerStatefulWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<HomeScreen> createState() => _HomeScreenState();
|
ConsumerState<HomeScreen> createState() => _HomeScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
|
class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObserver {
|
||||||
Map<String, dynamic>? _activeSession;
|
Map<String, dynamic>? _activeSession;
|
||||||
bool _loadingSession = true;
|
bool _loadingSession = true;
|
||||||
|
|
||||||
@@ -40,13 +41,12 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
|
|||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
// Re-check when navigating back to this screen
|
|
||||||
_checkActiveSession();
|
_checkActiveSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _checkActiveSession() async {
|
Future<void> _checkActiveSession() async {
|
||||||
try {
|
try {
|
||||||
final apiClient = context.read<ApiClient>();
|
final apiClient = ref.read(apiClientProvider);
|
||||||
final response = await apiClient.get('/api/client/chat/session/active');
|
final response = await apiClient.get('/api/client/chat/session/active');
|
||||||
final data = response['data'];
|
final data = response['data'];
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -62,6 +62,15 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final authState = ref.watch(authProvider);
|
||||||
|
final authData = authState.valueOrNull;
|
||||||
|
|
||||||
|
final displayName = switch (authData) {
|
||||||
|
AuthAuthenticatedData d => d.profile['display_name'] as String? ?? '',
|
||||||
|
AuthAnonymousData d => d.displayName,
|
||||||
|
_ => '',
|
||||||
|
};
|
||||||
|
|
||||||
return BlocListener<PairingBloc, PairingState>(
|
return BlocListener<PairingBloc, PairingState>(
|
||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
if (state is PairingSearching) {
|
if (state is PairingSearching) {
|
||||||
@@ -74,63 +83,53 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: BlocBuilder<AuthBloc, AuthState>(
|
child: Scaffold(
|
||||||
builder: (context, state) {
|
appBar: AppBar(
|
||||||
final displayName = state is AuthAuthenticated
|
title: const Text('Halo Bestie'),
|
||||||
? state.profile['display_name'] as String
|
actions: [
|
||||||
: state is AuthAnonymous
|
IconButton(
|
||||||
? state.displayName
|
icon: const Icon(Icons.history),
|
||||||
: '';
|
onPressed: () => context.push('/chat/history'),
|
||||||
|
),
|
||||||
return Scaffold(
|
IconButton(
|
||||||
appBar: AppBar(
|
icon: const Icon(Icons.logout),
|
||||||
title: const Text('Halo Bestie'),
|
onPressed: () => ref.read(authProvider.notifier).logout(),
|
||||||
actions: [
|
),
|
||||||
IconButton(
|
],
|
||||||
icon: const Icon(Icons.history),
|
),
|
||||||
onPressed: () => context.push('/chat/history'),
|
body: Center(
|
||||||
),
|
child: Padding(
|
||||||
IconButton(
|
padding: const EdgeInsets.all(32),
|
||||||
icon: const Icon(Icons.logout),
|
child: Column(
|
||||||
onPressed: () => context.read<AuthBloc>().add(LogoutRequested()),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
),
|
children: [
|
||||||
|
Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
if (_loadingSession)
|
||||||
|
const CircularProgressIndicator()
|
||||||
|
else if (_activeSession != null)
|
||||||
|
_ActiveSessionCard(
|
||||||
|
session: _activeSession!,
|
||||||
|
onTap: () {
|
||||||
|
final sessionId = _activeSession!['id'] as String;
|
||||||
|
final mitraName = _activeSession!['mitra_display_name'] as String? ?? 'Bestie';
|
||||||
|
context.push('/chat/session/$sessionId', extra: mitraName);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16),
|
||||||
|
),
|
||||||
|
onPressed: () => PricingBottomSheet.show(context),
|
||||||
|
child: const Text('Mulai Curhat', style: TextStyle(fontSize: 18)),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Center(
|
),
|
||||||
child: Padding(
|
),
|
||||||
padding: const EdgeInsets.all(32),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
if (_loadingSession)
|
|
||||||
const CircularProgressIndicator()
|
|
||||||
else if (_activeSession != null)
|
|
||||||
_ActiveSessionCard(
|
|
||||||
session: _activeSession!,
|
|
||||||
onTap: () {
|
|
||||||
final sessionId = _activeSession!['id'] as String;
|
|
||||||
final mitraName = _activeSession!['mitra_display_name'] as String? ?? 'Bestie';
|
|
||||||
context.push('/chat/session/$sessionId', extra: mitraName);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
else ...[
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
ElevatedButton(
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16),
|
|
||||||
),
|
|
||||||
onPressed: () => PricingBottomSheet.show(context),
|
|
||||||
child: const Text('Mulai Curhat', style: TextStyle(fontSize: 18)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import 'package:firebase_core/firebase_core.dart';
|
|||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'core/api/api_client.dart';
|
import 'core/api/api_client_provider.dart';
|
||||||
import 'core/auth/auth_bloc.dart';
|
import 'core/auth/auth_notifier.dart';
|
||||||
import 'core/chat/chat_bloc.dart';
|
import 'core/chat/chat_bloc.dart';
|
||||||
import 'core/chat/session_closure_bloc.dart';
|
import 'core/chat/session_closure_bloc.dart';
|
||||||
import 'core/pairing/pairing_bloc.dart';
|
import 'core/pairing/pairing_bloc.dart';
|
||||||
@@ -16,68 +16,64 @@ void main() async {
|
|||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||||
|
|
||||||
// Request notification permission
|
|
||||||
final messaging = FirebaseMessaging.instance;
|
final messaging = FirebaseMessaging.instance;
|
||||||
await messaging.requestPermission();
|
await messaging.requestPermission();
|
||||||
|
|
||||||
runApp(const App());
|
runApp(const ProviderScope(child: App()));
|
||||||
}
|
}
|
||||||
|
|
||||||
class App extends StatefulWidget {
|
class App extends ConsumerStatefulWidget {
|
||||||
const App({super.key});
|
const App({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<App> createState() => _AppState();
|
ConsumerState<App> createState() => _AppState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppState extends State<App> {
|
class _AppState extends ConsumerState<App> {
|
||||||
final _apiClient = ApiClient();
|
bool _fcmRegistered = false;
|
||||||
late final AuthBloc _authBloc;
|
|
||||||
late final GoRouter _router;
|
|
||||||
|
|
||||||
@override
|
void _registerFcmToken() {
|
||||||
void initState() {
|
if (_fcmRegistered) return;
|
||||||
super.initState();
|
_fcmRegistered = true;
|
||||||
_authBloc = AuthBloc(apiClient: _apiClient)..add(AppStarted());
|
Future(() async {
|
||||||
_router = buildRouter(_authBloc);
|
try {
|
||||||
NotificationService.initialize(_router);
|
final token = await FirebaseMessaging.instance.getToken();
|
||||||
_registerFcmToken();
|
if (token != null) {
|
||||||
}
|
await ref.read(apiClientProvider).post('/api/shared/device-token', data: {'token': token});
|
||||||
|
}
|
||||||
Future<void> _registerFcmToken() async {
|
} catch (_) {
|
||||||
// Listen for auth state, then register token
|
_fcmRegistered = false;
|
||||||
_authBloc.stream.listen((state) async {
|
|
||||||
if (state is AuthAuthenticated || state is AuthAnonymous) {
|
|
||||||
try {
|
|
||||||
final token = await FirebaseMessaging.instance.getToken();
|
|
||||||
if (token != null) {
|
|
||||||
await _apiClient.post('/api/shared/device-token', data: {'token': token});
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_authBloc.close();
|
|
||||||
_router.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// Listen for auth changes to register FCM token
|
||||||
|
ref.listen(authProvider, (prev, next) {
|
||||||
|
final data = next.valueOrNull;
|
||||||
|
if (data is AuthAuthenticatedData || data is AuthAnonymousData) {
|
||||||
|
_registerFcmToken();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final router = ref.watch(routerProvider);
|
||||||
|
final apiClient = ref.watch(apiClientProvider);
|
||||||
|
|
||||||
|
// Initialize notifications once router is available
|
||||||
|
NotificationService.initialize(router);
|
||||||
|
|
||||||
|
// Keep BlocProviders for non-migrated blocs (will be removed as they're migrated)
|
||||||
return MultiBlocProvider(
|
return MultiBlocProvider(
|
||||||
providers: [
|
providers: [
|
||||||
BlocProvider.value(value: _authBloc),
|
BlocProvider(create: (_) => PairingBloc(apiClient: apiClient)),
|
||||||
BlocProvider(create: (_) => PairingBloc(apiClient: _apiClient)),
|
BlocProvider(create: (_) => ChatBloc(apiClient: apiClient)),
|
||||||
BlocProvider(create: (_) => ChatBloc(apiClient: _apiClient)),
|
BlocProvider(create: (_) => SessionClosureBloc(apiClient: apiClient)),
|
||||||
BlocProvider(create: (_) => SessionClosureBloc(apiClient: _apiClient)),
|
RepositoryProvider.value(value: apiClient),
|
||||||
RepositoryProvider.value(value: _apiClient),
|
|
||||||
],
|
],
|
||||||
child: MaterialApp.router(
|
child: MaterialApp.router(
|
||||||
title: 'Halo Bestie',
|
title: 'Halo Bestie',
|
||||||
routerConfig: _router,
|
routerConfig: router,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'core/auth/auth_bloc.dart';
|
import 'core/auth/auth_notifier.dart';
|
||||||
import 'features/auth/screens/welcome_screen.dart';
|
import 'features/auth/screens/welcome_screen.dart';
|
||||||
import 'features/auth/screens/display_name_screen.dart';
|
import 'features/auth/screens/display_name_screen.dart';
|
||||||
import 'features/auth/screens/register_screen.dart';
|
import 'features/auth/screens/register_screen.dart';
|
||||||
@@ -16,38 +16,43 @@ import 'features/chat/screens/chat_screen.dart';
|
|||||||
import 'features/chat/screens/chat_history_screen.dart';
|
import 'features/chat/screens/chat_history_screen.dart';
|
||||||
import 'features/chat/screens/chat_transcript_screen.dart';
|
import 'features/chat/screens/chat_transcript_screen.dart';
|
||||||
|
|
||||||
/// Converts a BLoC stream into a ChangeNotifier for GoRouter's refreshListenable.
|
class RouterNotifier extends ChangeNotifier {
|
||||||
class _BlocRefreshNotifier extends ChangeNotifier {
|
final Ref _ref;
|
||||||
late final StreamSubscription _subscription;
|
|
||||||
|
|
||||||
_BlocRefreshNotifier(AuthBloc bloc) {
|
RouterNotifier(this._ref) {
|
||||||
_subscription = bloc.stream.listen((_) => notifyListeners());
|
_ref.listen(authProvider, (_, __) => notifyListeners());
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_subscription.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
GoRouter buildRouter(AuthBloc authBloc) {
|
final routerProvider = Provider<GoRouter>((ref) => buildRouter(ref));
|
||||||
|
|
||||||
|
GoRouter buildRouter(Ref ref) {
|
||||||
|
final notifier = RouterNotifier(ref);
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
initialLocation: '/splash',
|
initialLocation: '/splash',
|
||||||
refreshListenable: _BlocRefreshNotifier(authBloc),
|
refreshListenable: notifier,
|
||||||
redirect: (context, state) {
|
redirect: (context, state) {
|
||||||
final authState = authBloc.state;
|
final authState = ref.read(authProvider);
|
||||||
final isSplash = state.matchedLocation == '/splash';
|
final isSplash = state.matchedLocation == '/splash';
|
||||||
final isAuthRoute = state.matchedLocation.startsWith('/auth') ||
|
final isAuthRoute = state.matchedLocation.startsWith('/auth') ||
|
||||||
state.matchedLocation == '/welcome';
|
state.matchedLocation == '/welcome';
|
||||||
|
|
||||||
// Show splash while loading
|
// Show splash while loading
|
||||||
if (authState is AuthLoading) return isSplash ? null : '/splash';
|
if (authState is AsyncLoading) return isSplash ? null : '/splash';
|
||||||
|
|
||||||
if (authState is AuthAuthenticated || authState is AuthAnonymous) {
|
final data = authState.valueOrNull;
|
||||||
|
if (data == null) {
|
||||||
|
// Error state — show login
|
||||||
|
if (!isAuthRoute && !isSplash) return '/welcome';
|
||||||
|
if (isSplash) return '/welcome';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data is AuthAuthenticatedData || data is AuthAnonymousData) {
|
||||||
return (isSplash || isAuthRoute) ? '/home' : null;
|
return (isSplash || isAuthRoute) ? '/home' : null;
|
||||||
}
|
}
|
||||||
if (authState is AuthForceRegister) return '/auth/force-register';
|
if (data is AuthForceRegisterData) return '/auth/force-register';
|
||||||
if (!isAuthRoute && !isSplash) return '/welcome';
|
if (!isAuthRoute && !isSplash) return '/welcome';
|
||||||
if (isSplash) return '/welcome';
|
if (isSplash) return '/welcome';
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
packages:
|
||||||
|
_fe_analyzer_shared:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: _fe_analyzer_shared
|
||||||
|
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "85.0.0"
|
||||||
_flutterfire_internals:
|
_flutterfire_internals:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -9,6 +17,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.35"
|
version: "1.3.35"
|
||||||
|
analyzer:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: analyzer
|
||||||
|
sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.6.0"
|
||||||
|
analyzer_plugin:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: analyzer_plugin
|
||||||
|
sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.13.4"
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -41,6 +65,70 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.2"
|
||||||
|
build:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: build
|
||||||
|
sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.2"
|
||||||
|
build_config:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: build_config
|
||||||
|
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.2"
|
||||||
|
build_daemon:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: build_daemon
|
||||||
|
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.1"
|
||||||
|
build_resolvers:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: build_resolvers
|
||||||
|
sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.4"
|
||||||
|
build_runner:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: build_runner
|
||||||
|
sha256: "74691599a5bc750dc96a6b4bfd48f7d9d66453eab04c7f4063134800d6a5c573"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.14"
|
||||||
|
build_runner_core:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: build_runner_core
|
||||||
|
sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.0.0"
|
||||||
|
built_collection:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: built_collection
|
||||||
|
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.1.1"
|
||||||
|
built_value:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: built_value
|
||||||
|
sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.12.5"
|
||||||
characters:
|
characters:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -49,6 +137,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.0"
|
||||||
|
checked_yaml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: checked_yaml
|
||||||
|
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.4"
|
||||||
|
ci:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: ci
|
||||||
|
sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.0"
|
||||||
|
cli_util:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cli_util
|
||||||
|
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.4.2"
|
||||||
clock:
|
clock:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -57,6 +169,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
version: "1.1.2"
|
||||||
|
code_builder:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: code_builder
|
||||||
|
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.11.1"
|
||||||
collection:
|
collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -65,6 +185,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
|
convert:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: convert
|
||||||
|
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.2"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -73,6 +201,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
version: "3.0.7"
|
||||||
|
custom_lint:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: custom_lint
|
||||||
|
sha256: "9656925637516c5cf0f5da018b33df94025af2088fe09c8ae2ca54c53f2d9a84"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.6"
|
||||||
|
custom_lint_builder:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: custom_lint_builder
|
||||||
|
sha256: "6cdc8e87e51baaaba9c43e283ed8d28e59a0c4732279df62f66f7b5984655414"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.6"
|
||||||
|
custom_lint_core:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: custom_lint_core
|
||||||
|
sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.5"
|
||||||
|
custom_lint_visitor:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: custom_lint_visitor
|
||||||
|
sha256: "4a86a0d8415a91fbb8298d6ef03e9034dc8e323a599ddc4120a0e36c433983a2"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0+7.7.0"
|
||||||
|
dart_style:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dart_style
|
||||||
|
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.1"
|
||||||
dbus:
|
dbus:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -201,6 +369,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.8.7"
|
version: "3.8.7"
|
||||||
|
fixnum:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: fixnum
|
||||||
|
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -214,6 +390,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.1.6"
|
version: "8.1.6"
|
||||||
|
flutter_hooks:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_hooks
|
||||||
|
sha256: cde36b12f7188c85286fba9b38cc5a902e7279f36dd676967106c041dc9dde70
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.20.5"
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -254,6 +438,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.0"
|
version: "3.0.0"
|
||||||
|
flutter_riverpod:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_riverpod
|
||||||
|
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.6.1"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -264,6 +456,30 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
freezed_annotation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: freezed_annotation
|
||||||
|
sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.0"
|
||||||
|
frontend_server_client:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: frontend_server_client
|
||||||
|
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.0"
|
||||||
|
glob:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: glob
|
||||||
|
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.3"
|
||||||
go_router:
|
go_router:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -320,6 +536,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.4+4"
|
version: "0.12.4+4"
|
||||||
|
graphs:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: graphs
|
||||||
|
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.2"
|
||||||
|
hooks_riverpod:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: hooks_riverpod
|
||||||
|
sha256: "70bba33cfc5670c84b796e6929c54b8bc5be7d0fe15bb28c2560500b9ad06966"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.6.1"
|
||||||
|
hotreloader:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: hotreloader
|
||||||
|
sha256: "66871df468fc24eee81f1a0a7cb98acc104716f9b7376d355437b48d633c4ebf"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.4.0"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -328,6 +568,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.6.0"
|
version: "1.6.0"
|
||||||
|
http_multi_server:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http_multi_server
|
||||||
|
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.2"
|
||||||
http_parser:
|
http_parser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -336,6 +584,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
io:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: io
|
||||||
|
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.5"
|
||||||
|
js:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: js
|
||||||
|
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.2"
|
||||||
|
json_annotation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: json_annotation
|
||||||
|
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.11.0"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -416,6 +688,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
|
package_config:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_config
|
||||||
|
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -472,6 +752,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
version: "2.1.8"
|
||||||
|
pool:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pool
|
||||||
|
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.5.2"
|
||||||
provider:
|
provider:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -480,6 +768,70 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.5+1"
|
version: "6.1.5+1"
|
||||||
|
pub_semver:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pub_semver
|
||||||
|
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
pubspec_parse:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pubspec_parse
|
||||||
|
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.5.0"
|
||||||
|
riverpod:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: riverpod
|
||||||
|
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.6.1"
|
||||||
|
riverpod_analyzer_utils:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: riverpod_analyzer_utils
|
||||||
|
sha256: "03a17170088c63aab6c54c44456f5ab78876a1ddb6032ffde1662ddab4959611"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.5.10"
|
||||||
|
riverpod_annotation:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: riverpod_annotation
|
||||||
|
sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.6.1"
|
||||||
|
riverpod_generator:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: riverpod_generator
|
||||||
|
sha256: "44a0992d54473eb199ede00e2260bd3c262a86560e3c6f6374503d86d0580e36"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.6.5"
|
||||||
|
riverpod_lint:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: riverpod_lint
|
||||||
|
sha256: "89a52b7334210dbff8605c3edf26cfe69b15062beed5cbfeff2c3812c33c9e35"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.6.5"
|
||||||
|
rxdart:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: rxdart
|
||||||
|
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.28.0"
|
||||||
shared_preferences:
|
shared_preferences:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -536,6 +888,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
version: "2.4.1"
|
||||||
|
shelf:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shelf
|
||||||
|
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.2"
|
||||||
|
shelf_web_socket:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shelf_web_socket
|
||||||
|
sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.1"
|
||||||
sign_in_with_apple:
|
sign_in_with_apple:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -565,6 +933,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
source_gen:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_gen
|
||||||
|
sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.0"
|
||||||
source_span:
|
source_span:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -581,6 +957,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.12.1"
|
version: "1.12.1"
|
||||||
|
state_notifier:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: state_notifier
|
||||||
|
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
stream_channel:
|
stream_channel:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -589,6 +973,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.1.4"
|
||||||
|
stream_transform:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stream_transform
|
||||||
|
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.1"
|
||||||
string_scanner:
|
string_scanner:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -621,6 +1013,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.11.0"
|
version: "0.11.0"
|
||||||
|
timing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: timing
|
||||||
|
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.2"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -629,6 +1029,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.0"
|
||||||
|
uuid:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: uuid
|
||||||
|
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.5.3"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -645,6 +1053,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.0.2"
|
version: "15.0.2"
|
||||||
|
watcher:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: watcher
|
||||||
|
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.1"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -677,6 +1093,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.6.1"
|
version: "6.6.1"
|
||||||
|
yaml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: yaml
|
||||||
|
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.10.0 <4.0.0"
|
dart: ">=3.10.0 <4.0.0"
|
||||||
flutter: ">=3.38.1"
|
flutter: ">=3.38.1"
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ dependencies:
|
|||||||
# State management
|
# State management
|
||||||
flutter_bloc: ^8.1.5
|
flutter_bloc: ^8.1.5
|
||||||
equatable: ^2.0.5
|
equatable: ^2.0.5
|
||||||
|
flutter_riverpod: ^2.6.1
|
||||||
|
hooks_riverpod: ^2.6.1
|
||||||
|
riverpod_annotation: ^2.6.1
|
||||||
|
flutter_hooks: ^0.20.5
|
||||||
|
|
||||||
# Storage
|
# Storage
|
||||||
shared_preferences: ^2.2.3
|
shared_preferences: ^2.2.3
|
||||||
@@ -39,6 +43,10 @@ dev_dependencies:
|
|||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^3.0.0
|
flutter_lints: ^3.0.0
|
||||||
|
riverpod_generator: ^2.6.2
|
||||||
|
build_runner: ^2.4.13
|
||||||
|
custom_lint: ^0.7.0
|
||||||
|
riverpod_lint: ^2.6.2
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
<application
|
<application
|
||||||
android:label="mitra_app"
|
android:label="mitra_app"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
8
mitra_app/lib/core/api/api_client_provider.dart
Normal file
8
mitra_app/lib/core/api/api_client_provider.dart
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'api_client.dart';
|
||||||
|
|
||||||
|
part 'api_client_provider.g.dart';
|
||||||
|
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
ApiClient apiClient(Ref ref) => ApiClient();
|
||||||
26
mitra_app/lib/core/api/api_client_provider.g.dart
Normal file
26
mitra_app/lib/core/api/api_client_provider.g.dart
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'api_client_provider.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
String _$apiClientHash() => r'90c807f03b90249684265cc91739139c2c89eeb9';
|
||||||
|
|
||||||
|
/// See also [apiClient].
|
||||||
|
@ProviderFor(apiClient)
|
||||||
|
final apiClientProvider = Provider<ApiClient>.internal(
|
||||||
|
apiClient,
|
||||||
|
name: r'apiClientProvider',
|
||||||
|
debugGetCreateSourceHash:
|
||||||
|
const bool.fromEnvironment('dart.vm.product') ? null : _$apiClientHash,
|
||||||
|
dependencies: null,
|
||||||
|
allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||||
|
// ignore: unused_element
|
||||||
|
typedef ApiClientRef = ProviderRef<ApiClient>;
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||||
@@ -2,6 +2,7 @@ import 'package:firebase_core/firebase_core.dart';
|
|||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'core/api/api_client.dart';
|
import 'core/api/api_client.dart';
|
||||||
import 'core/auth/auth_bloc.dart';
|
import 'core/auth/auth_bloc.dart';
|
||||||
@@ -20,7 +21,7 @@ void main() async {
|
|||||||
final messaging = FirebaseMessaging.instance;
|
final messaging = FirebaseMessaging.instance;
|
||||||
await messaging.requestPermission();
|
await messaging.requestPermission();
|
||||||
|
|
||||||
runApp(const App());
|
runApp(const ProviderScope(child: App()));
|
||||||
}
|
}
|
||||||
|
|
||||||
class App extends StatefulWidget {
|
class App extends StatefulWidget {
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
packages:
|
||||||
|
_fe_analyzer_shared:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: _fe_analyzer_shared
|
||||||
|
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "85.0.0"
|
||||||
_flutterfire_internals:
|
_flutterfire_internals:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -9,6 +17,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.35"
|
version: "1.3.35"
|
||||||
|
analyzer:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: analyzer
|
||||||
|
sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.6.0"
|
||||||
|
analyzer_plugin:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: analyzer_plugin
|
||||||
|
sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.13.4"
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -41,6 +65,70 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.2"
|
||||||
|
build:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: build
|
||||||
|
sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.2"
|
||||||
|
build_config:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: build_config
|
||||||
|
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.2"
|
||||||
|
build_daemon:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: build_daemon
|
||||||
|
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.1"
|
||||||
|
build_resolvers:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: build_resolvers
|
||||||
|
sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.4"
|
||||||
|
build_runner:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: build_runner
|
||||||
|
sha256: "74691599a5bc750dc96a6b4bfd48f7d9d66453eab04c7f4063134800d6a5c573"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.14"
|
||||||
|
build_runner_core:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: build_runner_core
|
||||||
|
sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.0.0"
|
||||||
|
built_collection:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: built_collection
|
||||||
|
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.1.1"
|
||||||
|
built_value:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: built_value
|
||||||
|
sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.12.5"
|
||||||
characters:
|
characters:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -49,6 +137,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.0"
|
||||||
|
checked_yaml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: checked_yaml
|
||||||
|
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.4"
|
||||||
|
ci:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: ci
|
||||||
|
sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.0"
|
||||||
|
cli_util:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cli_util
|
||||||
|
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.4.2"
|
||||||
clock:
|
clock:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -57,6 +169,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
version: "1.1.2"
|
||||||
|
code_builder:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: code_builder
|
||||||
|
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.11.1"
|
||||||
collection:
|
collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -65,6 +185,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
|
convert:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: convert
|
||||||
|
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.2"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -73,6 +201,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
version: "3.0.7"
|
||||||
|
custom_lint:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: custom_lint
|
||||||
|
sha256: "9656925637516c5cf0f5da018b33df94025af2088fe09c8ae2ca54c53f2d9a84"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.6"
|
||||||
|
custom_lint_builder:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: custom_lint_builder
|
||||||
|
sha256: "6cdc8e87e51baaaba9c43e283ed8d28e59a0c4732279df62f66f7b5984655414"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.6"
|
||||||
|
custom_lint_core:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: custom_lint_core
|
||||||
|
sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.5"
|
||||||
|
custom_lint_visitor:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: custom_lint_visitor
|
||||||
|
sha256: "4a86a0d8415a91fbb8298d6ef03e9034dc8e323a599ddc4120a0e36c433983a2"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0+7.7.0"
|
||||||
|
dart_style:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dart_style
|
||||||
|
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.1"
|
||||||
dbus:
|
dbus:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -121,6 +289,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
|
file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file
|
||||||
|
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.1"
|
||||||
firebase_auth:
|
firebase_auth:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -193,6 +369,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.8.7"
|
version: "3.8.7"
|
||||||
|
fixnum:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: fixnum
|
||||||
|
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -206,6 +390,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.1.6"
|
version: "8.1.6"
|
||||||
|
flutter_hooks:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_hooks
|
||||||
|
sha256: cde36b12f7188c85286fba9b38cc5a902e7279f36dd676967106c041dc9dde70
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.20.5"
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -246,6 +438,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.0"
|
version: "3.0.0"
|
||||||
|
flutter_riverpod:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_riverpod
|
||||||
|
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.6.1"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -256,6 +456,30 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
freezed_annotation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: freezed_annotation
|
||||||
|
sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.0"
|
||||||
|
frontend_server_client:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: frontend_server_client
|
||||||
|
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.0"
|
||||||
|
glob:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: glob
|
||||||
|
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.3"
|
||||||
go_router:
|
go_router:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -264,6 +488,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "13.2.5"
|
version: "13.2.5"
|
||||||
|
graphs:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: graphs
|
||||||
|
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.2"
|
||||||
|
hooks_riverpod:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: hooks_riverpod
|
||||||
|
sha256: "70bba33cfc5670c84b796e6929c54b8bc5be7d0fe15bb28c2560500b9ad06966"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.6.1"
|
||||||
|
hotreloader:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: hotreloader
|
||||||
|
sha256: "66871df468fc24eee81f1a0a7cb98acc104716f9b7376d355437b48d633c4ebf"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.4.0"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -272,6 +520,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.6.0"
|
version: "1.6.0"
|
||||||
|
http_multi_server:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http_multi_server
|
||||||
|
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.2"
|
||||||
http_parser:
|
http_parser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -280,6 +536,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
io:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: io
|
||||||
|
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.5"
|
||||||
|
js:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: js
|
||||||
|
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.2"
|
||||||
|
json_annotation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: json_annotation
|
||||||
|
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.11.0"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -360,6 +640,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
|
package_config:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_config
|
||||||
|
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -384,6 +672,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
version: "2.1.8"
|
||||||
|
pool:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pool
|
||||||
|
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.5.2"
|
||||||
provider:
|
provider:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -392,11 +688,99 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.5+1"
|
version: "6.1.5+1"
|
||||||
|
pub_semver:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pub_semver
|
||||||
|
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
pubspec_parse:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pubspec_parse
|
||||||
|
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.5.0"
|
||||||
|
riverpod:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: riverpod
|
||||||
|
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.6.1"
|
||||||
|
riverpod_analyzer_utils:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: riverpod_analyzer_utils
|
||||||
|
sha256: "03a17170088c63aab6c54c44456f5ab78876a1ddb6032ffde1662ddab4959611"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.5.10"
|
||||||
|
riverpod_annotation:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: riverpod_annotation
|
||||||
|
sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.6.1"
|
||||||
|
riverpod_generator:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: riverpod_generator
|
||||||
|
sha256: "44a0992d54473eb199ede00e2260bd3c262a86560e3c6f6374503d86d0580e36"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.6.5"
|
||||||
|
riverpod_lint:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: riverpod_lint
|
||||||
|
sha256: "89a52b7334210dbff8605c3edf26cfe69b15062beed5cbfeff2c3812c33c9e35"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.6.5"
|
||||||
|
rxdart:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: rxdart
|
||||||
|
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.28.0"
|
||||||
|
shelf:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shelf
|
||||||
|
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.2"
|
||||||
|
shelf_web_socket:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shelf_web_socket
|
||||||
|
sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.1"
|
||||||
sky_engine:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
source_gen:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_gen
|
||||||
|
sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.0"
|
||||||
source_span:
|
source_span:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -413,6 +797,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.12.1"
|
version: "1.12.1"
|
||||||
|
state_notifier:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: state_notifier
|
||||||
|
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
stream_channel:
|
stream_channel:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -421,6 +813,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.1.4"
|
||||||
|
stream_transform:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stream_transform
|
||||||
|
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.1"
|
||||||
string_scanner:
|
string_scanner:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -453,6 +853,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.11.0"
|
version: "0.11.0"
|
||||||
|
timing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: timing
|
||||||
|
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.2"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -461,6 +869,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.0"
|
||||||
|
uuid:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: uuid
|
||||||
|
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.5.3"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -477,6 +893,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.0.2"
|
version: "15.0.2"
|
||||||
|
watcher:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: watcher
|
||||||
|
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.1"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -509,6 +933,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.6.1"
|
version: "6.6.1"
|
||||||
|
yaml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: yaml
|
||||||
|
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.10.0 <4.0.0"
|
dart: ">=3.10.0 <4.0.0"
|
||||||
flutter: ">=3.38.1"
|
flutter: ">=3.38.1"
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ dependencies:
|
|||||||
# State management
|
# State management
|
||||||
flutter_bloc: ^8.1.5
|
flutter_bloc: ^8.1.5
|
||||||
equatable: ^2.0.5
|
equatable: ^2.0.5
|
||||||
|
flutter_riverpod: ^2.6.1
|
||||||
|
hooks_riverpod: ^2.6.1
|
||||||
|
riverpod_annotation: ^2.6.1
|
||||||
|
flutter_hooks: ^0.20.5
|
||||||
|
|
||||||
# Navigation
|
# Navigation
|
||||||
go_router: ^13.2.1
|
go_router: ^13.2.1
|
||||||
@@ -32,6 +36,10 @@ dev_dependencies:
|
|||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^3.0.0
|
flutter_lints: ^3.0.0
|
||||||
|
riverpod_generator: ^2.6.2
|
||||||
|
build_runner: ^2.4.13
|
||||||
|
custom_lint: ^0.7.0
|
||||||
|
riverpod_lint: ^2.6.2
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|||||||
735
requirement/phase3.1-plan.md
Normal file
735
requirement/phase3.1-plan.md
Normal file
@@ -0,0 +1,735 @@
|
|||||||
|
# Phase 3.1 Implementation Plan: Riverpod Migration & FCM Fallback
|
||||||
|
|
||||||
|
## Summary of Clarified Requirements
|
||||||
|
|
||||||
|
| Topic | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Work stream order | Riverpod migration first, then FCM fallback |
|
||||||
|
| Riverpod style | Annotation-based (`@riverpod`) with code generation |
|
||||||
|
| Mitra AuthBloc bug | Fix stuck-loading: emit `AuthInitial` when `currentUser` is null |
|
||||||
|
| Mitra ping config | Control Center toggle: "require mitra ping" (boolean) + ping interval (seconds) |
|
||||||
|
| Non-ping mode | Mitra stays online without heartbeat; no auto-offline timeout; QC handles quality |
|
||||||
|
| Pairing FCM fallback | When WebSocket to mitra is closed, send pairing request via FCM push |
|
||||||
|
| Mitra pairing confirmation | Must manually accept (no auto-accept via FCM) |
|
||||||
|
| Unread badges (mitra) | Badge on "active sessions" button on home; badge on each session in list |
|
||||||
|
| Unread badges (customer) | Badge on `_ActiveSessionCard` widget on home screen |
|
||||||
|
| Badge clearing | Badges clear when messages are read |
|
||||||
|
| Closure FCM fallback | Backend sends closure signal to both parties; uses FCM if WebSocket is down |
|
||||||
|
| Closure screen | Must show closure screen on app (no silent updates) |
|
||||||
|
| Control center | New config: "require mitra ping" toggle + ping interval input |
|
||||||
|
| Backend changes for Riverpod | None — migration is Flutter-only |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Work Stream 1: Riverpod Migration (Flutter-only)
|
||||||
|
|
||||||
|
### 1.1 Dependency Changes
|
||||||
|
|
||||||
|
#### Both `client_app/pubspec.yaml` and `mitra_app/pubspec.yaml`
|
||||||
|
|
||||||
|
**Add to dependencies:**
|
||||||
|
|
||||||
|
| Package | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `flutter_riverpod` | Core Riverpod provider framework |
|
||||||
|
| `hooks_riverpod` | Riverpod + flutter_hooks integration (`HookConsumerWidget`) |
|
||||||
|
| `riverpod_annotation` | `@riverpod` / `@Riverpod(keepAlive: true)` annotations |
|
||||||
|
| `flutter_hooks` | Hook utilities (`useTextEditingController`, `useEffect`, etc.) |
|
||||||
|
|
||||||
|
**Add to dev_dependencies:**
|
||||||
|
|
||||||
|
| Package | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `riverpod_generator` | Code generation for `@riverpod` providers |
|
||||||
|
| `build_runner` | Runs code generation (`dart run build_runner build`) |
|
||||||
|
| `custom_lint` | Required for riverpod_lint rules |
|
||||||
|
| `riverpod_lint` | Lint rules for Riverpod best practices |
|
||||||
|
|
||||||
|
**Remove after all Blocs are migrated:**
|
||||||
|
|
||||||
|
| Package | |
|
||||||
|
|---|---|
|
||||||
|
| `flutter_bloc` | Replaced by Riverpod |
|
||||||
|
| `equatable` | No longer needed — Riverpod state is compared by value |
|
||||||
|
|
||||||
|
### 1.2 App Root Changes
|
||||||
|
|
||||||
|
#### `client_app/lib/main.dart`
|
||||||
|
|
||||||
|
**Current:** `MultiBlocProvider` wraps `MaterialApp.router` with `AuthBloc`, `PairingBloc`, `ChatBloc`, `SessionClosureBloc`.
|
||||||
|
|
||||||
|
**Target:**
|
||||||
|
1. Wrap `runApp` call with `ProviderScope`: `runApp(const ProviderScope(child: App()))`
|
||||||
|
2. Convert `App` from `StatefulWidget` to `HookConsumerWidget`
|
||||||
|
3. Remove `MultiBlocProvider` wrapper — providers are globally available via `ref`
|
||||||
|
4. Replace `_authBloc.stream.listen(...)` for FCM token registration with `ref.listen(authProvider, ...)`
|
||||||
|
5. Move `ApiClient` into a Riverpod provider: `@Riverpod(keepAlive: true) ApiClient apiClient(Ref ref) => ApiClient()`
|
||||||
|
6. Router creation: Use `ref.watch(authProvider)` to get auth state for redirect logic; replace `_BlocRefreshNotifier` with a Riverpod-based `ChangeNotifier` or use `ref.listen` on the auth provider
|
||||||
|
|
||||||
|
**Files changed:**
|
||||||
|
- `client_app/lib/main.dart`
|
||||||
|
- `client_app/lib/router.dart` (remove `_BlocRefreshNotifier`, accept `WidgetRef` or use a provider for router)
|
||||||
|
|
||||||
|
#### `mitra_app/lib/main.dart`
|
||||||
|
|
||||||
|
**Current:** `MultiBlocProvider` wraps app with `AuthBloc`, `StatusBloc`, `ChatRequestBloc`, `MitraChatBloc`, `ExtensionBloc`. Also has `WidgetsBindingObserver` for lifecycle and `BlocListener<AuthBloc>` to trigger `StatusLoadRequested`.
|
||||||
|
|
||||||
|
**Target:**
|
||||||
|
1. Wrap with `ProviderScope`
|
||||||
|
2. Convert `App` to `HookConsumerWidget`
|
||||||
|
3. Remove `MultiBlocProvider` — use `ref.watch()` / `ref.listen()` instead
|
||||||
|
4. Move lifecycle observer to a dedicated provider or custom hook (`useAppLifecycleState`)
|
||||||
|
5. Replace `BlocListener<AuthBloc>` triggering status load with `ref.listen(authProvider, ...)` inside a provider or widget
|
||||||
|
|
||||||
|
**Files changed:**
|
||||||
|
- `mitra_app/lib/main.dart`
|
||||||
|
- `mitra_app/lib/router.dart`
|
||||||
|
|
||||||
|
### 1.3 Migration Per Bloc — Client App
|
||||||
|
|
||||||
|
Migration order: AuthBloc (simplest, foundational) → ChatOpeningBloc (simple, no side effects) → SessionClosureBloc (simple API calls) → PairingBloc (WebSocket + timers) → ChatBloc (most complex, WebSocket + message state).
|
||||||
|
|
||||||
|
#### 1.3.1 `client_app` AuthBloc → AuthNotifier
|
||||||
|
|
||||||
|
**Source file:** `client_app/lib/core/auth/auth_bloc.dart`
|
||||||
|
|
||||||
|
**Current:** BLoC with 8 events (AppStarted, AnonymousLoginRequested, GoogleLoginRequested, AppleLoginRequested, PhoneOtpRequested, OtpVerified, LinkAccountRequested, LogoutRequested) and 7 state classes.
|
||||||
|
|
||||||
|
**Target:** `@Riverpod(keepAlive: true)` AsyncNotifier.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class Auth extends _$Auth {
|
||||||
|
// State type: AsyncValue<AuthData> (sealed class)
|
||||||
|
// Build method: checks Firebase currentUser, calls _verifyAndReturn or returns AuthData.initial
|
||||||
|
// Methods: loginAnonymous(displayName), loginGoogle(), loginApple(),
|
||||||
|
// requestOtp(phone), verifyOtp(verificationId, smsCode),
|
||||||
|
// linkAccount(), logout()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**State design:** Replace 7 separate state classes with a single sealed class:
|
||||||
|
```dart
|
||||||
|
sealed class AuthData {
|
||||||
|
const AuthData();
|
||||||
|
}
|
||||||
|
class AuthDataInitial extends AuthData { const AuthDataInitial(); }
|
||||||
|
class AuthDataAuthenticated extends AuthData { final Map<String, dynamic> profile; ... }
|
||||||
|
class AuthDataAnonymous extends AuthData { final String customerId; final String displayName; ... }
|
||||||
|
class AuthDataOtpSent extends AuthData { final String verificationId; ... }
|
||||||
|
class AuthDataForceRegister extends AuthData { final String customerId; final String displayName; ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
The `AsyncValue` wrapper handles loading/error automatically:
|
||||||
|
- `state = const AsyncLoading()` replaces `emit(AuthLoading())`
|
||||||
|
- `state = AsyncData(AuthDataAuthenticated(...))` replaces `emit(AuthAuthenticated(...))`
|
||||||
|
- `state = AsyncError(...)` replaces `emit(AuthError(...))`
|
||||||
|
|
||||||
|
**Widget changes:**
|
||||||
|
- `BlocBuilder<AuthBloc, AuthState>` → `ConsumerWidget` + `ref.watch(authProvider)`
|
||||||
|
- `BlocListener<AuthBloc, AuthState>` → `ref.listen(authProvider, ...)`
|
||||||
|
- `context.read<AuthBloc>().add(LogoutRequested())` → `ref.read(authProvider.notifier).logout()`
|
||||||
|
|
||||||
|
**Files affected:**
|
||||||
|
- `client_app/lib/core/auth/auth_bloc.dart` → `auth_notifier.dart` + `.g.dart`
|
||||||
|
- `client_app/lib/features/auth/screens/welcome_screen.dart`
|
||||||
|
- `client_app/lib/features/auth/screens/display_name_screen.dart`
|
||||||
|
- `client_app/lib/features/auth/screens/register_screen.dart`
|
||||||
|
- `client_app/lib/features/auth/screens/otp_screen.dart`
|
||||||
|
- `client_app/lib/features/auth/screens/force_register_screen.dart`
|
||||||
|
- `client_app/lib/features/home/home_screen.dart`
|
||||||
|
- `client_app/lib/router.dart`
|
||||||
|
- `client_app/lib/main.dart`
|
||||||
|
|
||||||
|
#### 1.3.2 `client_app` ChatOpeningBloc → ChatOpeningNotifier
|
||||||
|
|
||||||
|
**Source file:** `client_app/lib/core/chat/chat_opening_bloc.dart`
|
||||||
|
|
||||||
|
**Current:** Simple BLoC with one event (`LoadPricing`) and 4 states. Fetches pricing tiers from API.
|
||||||
|
|
||||||
|
**Target:** `@riverpod` FutureProvider (auto-dispose, since pricing is ephemeral):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@riverpod
|
||||||
|
Future<PricingData> chatPricing(Ref ref) async {
|
||||||
|
final apiClient = ref.read(apiClientProvider);
|
||||||
|
final response = await apiClient.get('/api/client/chat/pricing');
|
||||||
|
// parse and return PricingData
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**PriceTier model** stays the same; move out of bloc file into a shared models file.
|
||||||
|
|
||||||
|
**Files affected:**
|
||||||
|
- `client_app/lib/core/chat/chat_opening_bloc.dart` → `chat_opening_notifier.dart` + `.g.dart`
|
||||||
|
- `client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart`
|
||||||
|
|
||||||
|
#### 1.3.3 `client_app` SessionClosureBloc → SessionClosureNotifier
|
||||||
|
|
||||||
|
**Source file:** `client_app/lib/core/chat/session_closure_bloc.dart`
|
||||||
|
|
||||||
|
**Current:** BLoC with 4 events (RequestExtension, DeclineExtension, ResetClosure, SubmitGoodbye) and 6 states.
|
||||||
|
|
||||||
|
**Target:** `@riverpod` Notifier (synchronous state, async methods).
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@riverpod
|
||||||
|
class SessionClosure extends _$SessionClosure {
|
||||||
|
// State: SessionClosureData sealed class
|
||||||
|
// (Initial, ExtendingWaitingMitra, ShowGoodbye, Submitting, Complete, Error)
|
||||||
|
// build(): returns SessionClosureData.initial
|
||||||
|
// requestExtension(sessionId, durationMinutes, price)
|
||||||
|
// declineExtension()
|
||||||
|
// reset()
|
||||||
|
// submitGoodbye(sessionId, message)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Files affected:**
|
||||||
|
- `client_app/lib/core/chat/session_closure_bloc.dart` → `session_closure_notifier.dart` + `.g.dart`
|
||||||
|
- `client_app/lib/features/chat/screens/chat_screen.dart`
|
||||||
|
|
||||||
|
#### 1.3.4 `client_app` PairingBloc → PairingNotifier
|
||||||
|
|
||||||
|
**Source file:** `client_app/lib/core/pairing/pairing_bloc.dart`
|
||||||
|
|
||||||
|
**Current:** BLoC with WebSocket connection, 60s timeout timer, pairing request flow. 6 public events + 3 private events, 7 state classes.
|
||||||
|
|
||||||
|
**Target:** `@Riverpod(keepAlive: true)` Notifier with internal WebSocket and timer management.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class Pairing extends _$Pairing {
|
||||||
|
WebSocketChannel? _channel;
|
||||||
|
Timer? _timeoutTimer;
|
||||||
|
StreamSubscription? _wsSubscription;
|
||||||
|
|
||||||
|
// State: PairingData sealed class
|
||||||
|
// (Initial, Searching, BestieFound, Active, NoBestie, Cancelled, Error)
|
||||||
|
// build(): returns PairingData.initial
|
||||||
|
// requestPairing()
|
||||||
|
// requestPairingWithTier({durationMinutes, price, isFreeTrial})
|
||||||
|
// cancelPairing()
|
||||||
|
// Internal: _connectWebSocket(), _onStatusUpdate(), _cleanup()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key difference from BLoC:** Private events (`_PairingStatusUpdate`, `_PairingTimeout`, `_ConnectionError`) become direct method calls within the notifier since Riverpod notifiers can call `state = ...` from callbacks without needing to route through an event system.
|
||||||
|
|
||||||
|
**Files affected:**
|
||||||
|
- `client_app/lib/core/pairing/pairing_bloc.dart` → `pairing_notifier.dart` + `.g.dart`
|
||||||
|
- `client_app/lib/features/home/home_screen.dart`
|
||||||
|
- `client_app/lib/features/chat/screens/searching_screen.dart`
|
||||||
|
- `client_app/lib/features/chat/screens/no_bestie_screen.dart`
|
||||||
|
- `client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart`
|
||||||
|
|
||||||
|
#### 1.3.5 `client_app` ChatBloc → ChatNotifier
|
||||||
|
|
||||||
|
**Source file:** `client_app/lib/core/chat/chat_bloc.dart`
|
||||||
|
|
||||||
|
**Current:** Most complex BLoC. Manages WebSocket connection, message list, typing indicators, session timer, message delivery/read status. 8 events, 4 state classes. `ChatConnected` has `copyWith` for granular updates.
|
||||||
|
|
||||||
|
**Target:** `@riverpod` Notifier with internal WebSocket management.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@riverpod
|
||||||
|
class Chat extends _$Chat {
|
||||||
|
WebSocketChannel? _channel;
|
||||||
|
StreamSubscription? _wsSubscription;
|
||||||
|
Timer? _typingTimer;
|
||||||
|
|
||||||
|
// State: ChatData sealed class
|
||||||
|
// (Initial, Connecting, Connected, Error)
|
||||||
|
// ChatDataConnected holds: messages, isOtherTyping, remainingSeconds,
|
||||||
|
// sessionExpired, sessionPaused, sessionClosing, extensionResponse
|
||||||
|
// build(): returns ChatData.initial
|
||||||
|
// connect(sessionId), disconnect()
|
||||||
|
// sendMessage(content), sendTyping()
|
||||||
|
// markDelivered(messageIds), markRead(messageIds)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Files affected:**
|
||||||
|
- `client_app/lib/core/chat/chat_bloc.dart` → `chat_notifier.dart` + `.g.dart`
|
||||||
|
- `client_app/lib/features/chat/screens/chat_screen.dart`
|
||||||
|
- `client_app/lib/features/chat/screens/bestie_found_screen.dart`
|
||||||
|
|
||||||
|
### 1.4 Migration Per Bloc — Mitra App
|
||||||
|
|
||||||
|
Migration order: AuthBloc (fix bug here) → StatusBloc (timer management) → ExtensionBloc (simple) → ChatRequestBloc (WebSocket) → MitraChatBloc (most complex).
|
||||||
|
|
||||||
|
#### 1.4.1 `mitra_app` AuthBloc → AuthNotifier (BUG FIX)
|
||||||
|
|
||||||
|
**Source file:** `mitra_app/lib/core/auth/auth_bloc.dart`
|
||||||
|
|
||||||
|
**Bug:** Lines 65-68 — `_onAppStarted` only calls `_verifyAndEmit` when `_auth.currentUser != null`, but does NOT emit `AuthInitial` when `currentUser` is null. This leaves the app stuck in `AuthLoading`. The client_app's version correctly has `else { emit(AuthInitial()); }`.
|
||||||
|
|
||||||
|
**Fix during migration:** The `build()` method of the new AsyncNotifier must return `AuthDataInitial` when `currentUser` is null.
|
||||||
|
|
||||||
|
**Target:** `@Riverpod(keepAlive: true)` AsyncNotifier.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class Auth extends _$Auth {
|
||||||
|
ConfirmationResult? _webConfirmationResult;
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<MitraAuthData> build() async {
|
||||||
|
final currentUser = FirebaseAuth.instance.currentUser;
|
||||||
|
if (currentUser != null) {
|
||||||
|
return await _verifyAndReturn(); // returns MitraAuthData.authenticated(profile)
|
||||||
|
}
|
||||||
|
return const MitraAuthData.initial(); // FIX: explicitly return initial state
|
||||||
|
}
|
||||||
|
// Methods: requestOtp(phone), verifyOtp(verificationId, smsCode), logout()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Files affected:**
|
||||||
|
- `mitra_app/lib/core/auth/auth_bloc.dart` → `auth_notifier.dart` + `.g.dart`
|
||||||
|
- `mitra_app/lib/features/auth/screens/login_screen.dart`
|
||||||
|
- `mitra_app/lib/features/auth/screens/otp_screen.dart`
|
||||||
|
- `mitra_app/lib/features/home/home_screen.dart`
|
||||||
|
- `mitra_app/lib/router.dart`
|
||||||
|
- `mitra_app/lib/main.dart`
|
||||||
|
|
||||||
|
#### 1.4.2 `mitra_app` StatusBloc → StatusNotifier
|
||||||
|
|
||||||
|
**Source file:** `mitra_app/lib/core/status/status_bloc.dart`
|
||||||
|
|
||||||
|
**Current:** BLoC with 6 events, heartbeat timer management, 4 states.
|
||||||
|
|
||||||
|
**Target:** `@Riverpod(keepAlive: true)` Notifier (keepAlive because status persists across screens).
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class OnlineStatus extends _$OnlineStatus {
|
||||||
|
Timer? _heartbeatTimer;
|
||||||
|
|
||||||
|
// State: OnlineStatusData (Initial, Loaded{isOnline}, Loading, Error)
|
||||||
|
// build(): returns OnlineStatusData.initial
|
||||||
|
// load(), toggleOnline(), toggleOffline(), onAppPaused(), onAppResumed()
|
||||||
|
// Private: _startHeartbeat(), _stopHeartbeat(), _heartbeatTick()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Files affected:**
|
||||||
|
- `mitra_app/lib/core/status/status_bloc.dart` → `online_status_notifier.dart` + `.g.dart`
|
||||||
|
- `mitra_app/lib/features/home/home_screen.dart`
|
||||||
|
- `mitra_app/lib/main.dart` (lifecycle handling)
|
||||||
|
|
||||||
|
#### 1.4.3 `mitra_app` ExtensionBloc → ExtensionNotifier
|
||||||
|
|
||||||
|
**Source file:** `mitra_app/lib/core/chat/extension_bloc.dart`
|
||||||
|
|
||||||
|
**Current:** Simple BLoC with 2 events, 6 states.
|
||||||
|
|
||||||
|
**Target:** `@riverpod` Notifier.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@riverpod
|
||||||
|
class Extension extends _$Extension {
|
||||||
|
// State: ExtensionData (Idle, Responding, ShowGoodbye, Submitting, Complete, Error)
|
||||||
|
// build(): returns ExtensionData.idle
|
||||||
|
// respond(sessionId, extensionId, accepted)
|
||||||
|
// submitGoodbye(sessionId, message)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Files affected:**
|
||||||
|
- `mitra_app/lib/core/chat/extension_bloc.dart` → `extension_notifier.dart` + `.g.dart`
|
||||||
|
- `mitra_app/lib/features/chat/screens/mitra_chat_screen.dart`
|
||||||
|
|
||||||
|
#### 1.4.4 `mitra_app` ChatRequestBloc → ChatRequestNotifier
|
||||||
|
|
||||||
|
**Source file:** `mitra_app/lib/core/chat/chat_request_bloc.dart`
|
||||||
|
|
||||||
|
**Current:** BLoC with WebSocket connection for incoming chat requests. 6 events, 6 states.
|
||||||
|
|
||||||
|
**Target:** `@Riverpod(keepAlive: true)` Notifier.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class ChatRequest extends _$ChatRequest {
|
||||||
|
WebSocketChannel? _channel;
|
||||||
|
StreamSubscription? _wsSubscription;
|
||||||
|
|
||||||
|
// State: ChatRequestData
|
||||||
|
// (Idle, Listening, Incoming{sessionId}, Accepting, Accepted{session}, Error)
|
||||||
|
// build(): returns ChatRequestData.idle
|
||||||
|
// startListening(), stopListening(), accept(sessionId), decline(sessionId)
|
||||||
|
// Private: _connectWebSocket(), _onRequestReceived(), _closeWebSocket()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Files affected:**
|
||||||
|
- `mitra_app/lib/core/chat/chat_request_bloc.dart` → `chat_request_notifier.dart` + `.g.dart`
|
||||||
|
- `mitra_app/lib/features/home/home_screen.dart`
|
||||||
|
- `mitra_app/lib/features/chat/widgets/incoming_request_sheet.dart`
|
||||||
|
|
||||||
|
#### 1.4.5 `mitra_app` MitraChatBloc → MitraChatNotifier
|
||||||
|
|
||||||
|
**Source file:** `mitra_app/lib/core/chat/mitra_chat_bloc.dart`
|
||||||
|
|
||||||
|
**Current:** Mirrors client ChatBloc closely. WebSocket + message list + typing + session events. 8 events, 4 states.
|
||||||
|
|
||||||
|
**Target:** `@riverpod` Notifier.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@riverpod
|
||||||
|
class MitraChat extends _$MitraChat {
|
||||||
|
WebSocketChannel? _channel;
|
||||||
|
StreamSubscription? _wsSubscription;
|
||||||
|
Timer? _typingTimer;
|
||||||
|
|
||||||
|
// State: MitraChatData
|
||||||
|
// (Initial, Connecting, Connected{messages, isOtherTyping, ...}, Error)
|
||||||
|
// build(): returns MitraChatData.initial
|
||||||
|
// connect(sessionId), disconnect(), sendMessage(content), sendTyping(),
|
||||||
|
// markDelivered(ids), markRead(ids)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Files affected:**
|
||||||
|
- `mitra_app/lib/core/chat/mitra_chat_bloc.dart` → `mitra_chat_notifier.dart` + `.g.dart`
|
||||||
|
- `mitra_app/lib/features/chat/screens/mitra_chat_screen.dart`
|
||||||
|
- `mitra_app/lib/features/chat/screens/active_sessions_screen.dart`
|
||||||
|
|
||||||
|
### 1.5 Router Changes
|
||||||
|
|
||||||
|
Both apps use a `_BlocRefreshNotifier` that listens to the AuthBloc stream to trigger GoRouter redirects. Replace with a Riverpod-based approach:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class RouterNotifier extends ChangeNotifier {
|
||||||
|
RouterNotifier(this._ref) {
|
||||||
|
_ref.listen(authProvider, (_, __) => notifyListeners());
|
||||||
|
}
|
||||||
|
final Ref _ref;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass as `refreshListenable` to GoRouter. The redirect function reads auth state via `_ref.read(authProvider)`.
|
||||||
|
|
||||||
|
**Files changed:**
|
||||||
|
- `client_app/lib/router.dart`
|
||||||
|
- `mitra_app/lib/router.dart`
|
||||||
|
|
||||||
|
### 1.6 Code Generation
|
||||||
|
|
||||||
|
After each migration step, run:
|
||||||
|
```bash
|
||||||
|
dart run build_runner build --delete-conflicting-outputs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.7 Final Cleanup
|
||||||
|
|
||||||
|
After all Blocs are migrated and verified:
|
||||||
|
1. Remove `flutter_bloc` and `equatable` from both `pubspec.yaml` files
|
||||||
|
2. Delete old Bloc files
|
||||||
|
3. Run `flutter pub get` to verify no remaining references
|
||||||
|
4. Global search for any remaining `BlocProvider`, `BlocBuilder`, `BlocListener`, `context.read<`, `context.watch<` — replace any stragglers
|
||||||
|
|
||||||
|
### 1.8 Testing Checklist — Riverpod Migration
|
||||||
|
|
||||||
|
| Test | App | What to verify |
|
||||||
|
|---|---|---|
|
||||||
|
| Auth flow | client_app | Anonymous login, Google login, Apple login, OTP login, account linking, logout |
|
||||||
|
| Auth flow | mitra_app | OTP login, logout, **verify stuck-loading bug is fixed** |
|
||||||
|
| Router redirect | Both | Unauthenticated → login screen; authenticated → home; splash transitions correctly |
|
||||||
|
| Pricing dialog | client_app | Pricing tiers load, free trial shows when eligible, tier selection triggers pairing |
|
||||||
|
| Pairing flow | client_app | Request pairing, searching state, bestie found transition, cancel pairing, timeout |
|
||||||
|
| Chat connect/send | client_app | WebSocket connects, messages send/receive, typing indicator, delivery/read status |
|
||||||
|
| Session closure | client_app | Extension request, decline extension → goodbye, submit goodbye |
|
||||||
|
| Status toggle | mitra_app | Go online, go offline, heartbeat fires every 15s, app lifecycle pause/resume |
|
||||||
|
| Chat requests | mitra_app | Start listening when online, incoming request sheet, accept, decline |
|
||||||
|
| Mitra chat | mitra_app | Connect to session, send/receive messages, typing, extension request handling |
|
||||||
|
| Extension | mitra_app | Accept/reject extension, goodbye message submission |
|
||||||
|
| App lifecycle | mitra_app | Backgrounding stops heartbeat, foregrounding resumes if online |
|
||||||
|
| FCM token | Both | Token registers after auth, token re-registers on app relaunch |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Work Stream 2: FCM Fallback for Chat Engine
|
||||||
|
|
||||||
|
### 2.1 Database Changes
|
||||||
|
|
||||||
|
#### New `app_config` keys
|
||||||
|
|
||||||
|
| Key | Default Value (JSONB) | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `require_mitra_ping` | `{ "value": true }` | Whether mitra must heartbeat to stay online |
|
||||||
|
| `mitra_ping_interval_seconds` | `{ "value": 15 }` | How often mitra must ping (configurable) |
|
||||||
|
|
||||||
|
**Migration addition to `backend/src/db/migrate.js`:**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO app_config (key, value) VALUES ('require_mitra_ping', '{"value": true}') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO app_config (key, value) VALUES ('mitra_ping_interval_seconds', '{"value": 15}') ON CONFLICT (key) DO NOTHING;
|
||||||
|
```
|
||||||
|
|
||||||
|
No new tables needed. Existing `chat_messages` table with `status` and `read_at` columns is sufficient for unread counts.
|
||||||
|
|
||||||
|
### 2.2 Backend Changes
|
||||||
|
|
||||||
|
#### 2.2.1 Config Service Updates
|
||||||
|
|
||||||
|
**File:** `backend/src/services/config.service.js`
|
||||||
|
|
||||||
|
Add two new functions:
|
||||||
|
- `getMitraPingConfig()` — returns `{ require_ping, ping_interval_seconds }`
|
||||||
|
- `setMitraPingConfig({ require_ping, ping_interval_seconds })` — upserts both keys
|
||||||
|
|
||||||
|
#### 2.2.2 Internal Config Routes
|
||||||
|
|
||||||
|
**File:** `backend/src/routes/internal/config.routes.js`
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/internal/config/mitra-ping` | Get require_ping + interval |
|
||||||
|
| `PATCH` | `/internal/config/mitra-ping` | Update require_ping and/or interval |
|
||||||
|
|
||||||
|
#### 2.2.3 Mitra Status Service Updates
|
||||||
|
|
||||||
|
**File:** `backend/src/services/mitra-status.service.js`
|
||||||
|
|
||||||
|
- Modify `autoOfflineStaleMitras`: if `require_ping` is `false`, skip the auto-offline sweep entirely; if `true`, use `ping_interval_seconds * 3` as the staleness threshold
|
||||||
|
- Modify `heartbeat`: if `require_ping` is `false`, return early (no-op)
|
||||||
|
- Add ping config to status GET response so mitra app knows the expected interval
|
||||||
|
|
||||||
|
#### 2.2.4 Mitra Status Routes Update
|
||||||
|
|
||||||
|
**File:** `backend/src/routes/public/mitra.status.routes.js`
|
||||||
|
|
||||||
|
Update `GET /api/mitra/status` response to include:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"is_online": true,
|
||||||
|
"require_ping": true,
|
||||||
|
"ping_interval_seconds": 15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2.5 Pairing Service FCM Fallback Enhancement
|
||||||
|
|
||||||
|
**File:** `backend/src/services/pairing.service.js`
|
||||||
|
|
||||||
|
The existing `notifyMitra` already has FCM fallback. Enhancements needed:
|
||||||
|
1. FCM payload includes `session_id` for deep-linking
|
||||||
|
2. FCM notification shows confirmation that mitra must tap to accept
|
||||||
|
3. No auto-accept path from FCM — mitra must open app and manually accept
|
||||||
|
|
||||||
|
**Updated FCM payload:**
|
||||||
|
```javascript
|
||||||
|
await sendPushNotification(UserType.MITRA, mitraId, {
|
||||||
|
title: 'Permintaan Chat Baru',
|
||||||
|
body: 'Ada pelanggan yang ingin curhat! Ketuk untuk menerima.',
|
||||||
|
data: {
|
||||||
|
type: WsMessage.CHAT_REQUEST,
|
||||||
|
session_id: data.session_id,
|
||||||
|
action: 'open_accept',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2.6 Closure Service FCM Fallback
|
||||||
|
|
||||||
|
**File:** `backend/src/services/closure.service.js`
|
||||||
|
|
||||||
|
In `initiateEarlyEnd` and `completeSession`, after sending WebSocket closure signals, add FCM fallback:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
if (!isUserOnlineWs(UserType.CUSTOMER, session.customer_id)) {
|
||||||
|
await sendPushNotification(UserType.CUSTOMER, session.customer_id, {
|
||||||
|
title: 'Sesi Berakhir',
|
||||||
|
body: 'Sesi curhat kamu telah berakhir. Ketuk untuk menulis pesan penutup.',
|
||||||
|
data: { type: WsMessage.SESSION_CLOSING, session_id: sessionId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Same for mitra
|
||||||
|
```
|
||||||
|
|
||||||
|
**File:** `backend/src/services/session-timer.service.js`
|
||||||
|
|
||||||
|
Same fix in `onSessionExpired` — add FCM fallback for both parties after `SESSION_EXPIRED` and `SESSION_CLOSING` WebSocket messages.
|
||||||
|
|
||||||
|
#### 2.2.7 Unread Count API
|
||||||
|
|
||||||
|
**New endpoints:**
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/api/mitra/chat-requests/sessions/active-with-unread` | Active sessions + unread count per session |
|
||||||
|
| `GET` | `/api/client/chat/session/active-with-unread` | Active session + unread count |
|
||||||
|
|
||||||
|
**File:** `backend/src/services/session.service.js`
|
||||||
|
|
||||||
|
Add `getActiveSessionsByMitraWithUnread(mitraId)` — joins `chat_sessions` with a subquery counting unread messages (where `sender_type = 'customer'` and `status IN ('sent', 'delivered')`).
|
||||||
|
|
||||||
|
Add `getActiveSessionByCustomerWithUnread(customerId)` — same pattern for customer side.
|
||||||
|
|
||||||
|
### 2.3 Flutter Changes — Mitra App
|
||||||
|
|
||||||
|
#### 2.3.1 Status Notifier Updates (Ping Config)
|
||||||
|
|
||||||
|
**File:** `mitra_app/lib/core/status/online_status_notifier.dart`
|
||||||
|
|
||||||
|
1. Fetch `require_ping` and `ping_interval_seconds` from status API response
|
||||||
|
2. If `require_ping` is `false`, do NOT start heartbeat timer
|
||||||
|
3. If `require_ping` is `true`, use `ping_interval_seconds` from config (not hardcoded 15s)
|
||||||
|
4. On `AppPaused`: if `require_ping` is false, do nothing; if true, stop heartbeat as before
|
||||||
|
|
||||||
|
#### 2.3.2 Unread Badge Provider
|
||||||
|
|
||||||
|
**New file:** `mitra_app/lib/core/chat/unread_notifier.dart`
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class UnreadSessions extends _$UnreadSessions {
|
||||||
|
// Returns Map<String, int> — { sessionId: unreadCount }
|
||||||
|
// Polls every 10-30s or updates via WebSocket
|
||||||
|
// totalUnread getter: sum of all values
|
||||||
|
// markSessionRead(sessionId): optimistic update sets count to 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.3.3 Home Screen Badge
|
||||||
|
|
||||||
|
**File:** `mitra_app/lib/features/home/home_screen.dart`
|
||||||
|
|
||||||
|
Add `Badge` widget wrapping the active sessions button icon, showing `totalUnread` count.
|
||||||
|
|
||||||
|
#### 2.3.4 Active Sessions Screen Badge
|
||||||
|
|
||||||
|
**File:** `mitra_app/lib/features/chat/screens/active_sessions_screen.dart`
|
||||||
|
|
||||||
|
Show `Badge` on each session's `ListTile` with per-session unread count. Badge clears when user enters the session (mark-read via WebSocket).
|
||||||
|
|
||||||
|
#### 2.3.5 Notification Service Updates
|
||||||
|
|
||||||
|
**File:** `mitra_app/lib/core/notifications/notification_service.dart`
|
||||||
|
|
||||||
|
Handle FCM-delivered messages:
|
||||||
|
- `type: chat_request` → navigate to home screen, show incoming request bottom sheet
|
||||||
|
- `type: session_closing` → navigate to the chat session closure screen
|
||||||
|
|
||||||
|
### 2.4 Flutter Changes — Client App
|
||||||
|
|
||||||
|
#### 2.4.1 Unread Badge Provider
|
||||||
|
|
||||||
|
**New file:** `client_app/lib/core/chat/unread_notifier.dart`
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class UnreadCount extends _$UnreadCount {
|
||||||
|
// Returns int — total unread count for active session
|
||||||
|
// markRead(): sets to 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.4.2 Home Screen Badge
|
||||||
|
|
||||||
|
**File:** `client_app/lib/features/home/home_screen.dart`
|
||||||
|
|
||||||
|
Add `Badge` widget on `_ActiveSessionCard`'s `CircleAvatar`, showing unread count.
|
||||||
|
|
||||||
|
#### 2.4.3 Notification Service Update
|
||||||
|
|
||||||
|
**File:** `client_app/lib/core/notifications/notification_service.dart`
|
||||||
|
|
||||||
|
Handle closure FCM: `type: session_closing` → navigate to chat session screen (shows closure UI).
|
||||||
|
|
||||||
|
### 2.5 Control Center Changes
|
||||||
|
|
||||||
|
**File:** `control_center/src/pages/settings/SettingsPage.jsx`
|
||||||
|
|
||||||
|
Add new section for mitra ping configuration:
|
||||||
|
- Checkbox: "Wajibkan Mitra Ping (Heartbeat)" — toggle `require_mitra_ping`
|
||||||
|
- Number input: "Interval Ping" — sets `mitra_ping_interval_seconds`
|
||||||
|
- Helper text explaining that disabling ping means QC is responsible for mitra quality
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Implementation Order
|
||||||
|
|
||||||
|
| Step | What | Apps Affected | Dependencies |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Work Stream 1: Riverpod Migration** | | | |
|
||||||
|
| 1 | Add Riverpod dependencies to both pubspec.yaml | client_app, mitra_app | None |
|
||||||
|
| 2 | Wrap app root with ProviderScope, create ApiClient provider | client_app, mitra_app | Step 1 |
|
||||||
|
| 3 | Migrate client_app AuthBloc → AuthNotifier | client_app | Step 2 |
|
||||||
|
| 4 | Update client_app router to use Riverpod auth state | client_app | Step 3 |
|
||||||
|
| 5 | Migrate client_app ChatOpeningBloc → ChatOpeningNotifier | client_app | Step 3 |
|
||||||
|
| 6 | Migrate client_app SessionClosureBloc → SessionClosureNotifier | client_app | Step 3 |
|
||||||
|
| 7 | Migrate client_app PairingBloc → PairingNotifier | client_app | Step 3 |
|
||||||
|
| 8 | Migrate client_app ChatBloc → ChatNotifier | client_app | Step 3, 6, 7 |
|
||||||
|
| 9 | E2E test client_app (all flows) | client_app | Steps 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 |
|
||||||
84
requirement/phase3.1.md
Normal file
84
requirement/phase3.1.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# PRD: Phase 3 Stabilization & State Management Migration
|
||||||
|
|
||||||
|
# Overview
|
||||||
|
|
||||||
|
**Goal:** Stabilize Phase 3 (Chat Engine) through end-to-end testing and migrate Flutter state management from BLoC to Riverpod + flutter_hooks
|
||||||
|
|
||||||
|
**Success looks like:** All Phase 3 features are verified working end-to-end across client_app, mitra_app, and control_center. Both Flutter apps use Riverpod as their sole state management solution.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
- Phase 3 (Chat Engine) is fully scaffolded but has not been end-to-end tested
|
||||||
|
- Current Flutter apps use BLoC pattern; Riverpod is preferred for maintainability and reduced boilerplate
|
||||||
|
- Migration should happen before Phase 4 to avoid compounding tech debt
|
||||||
|
|
||||||
|
|
||||||
|
## FCM fallback for Chat Engine
|
||||||
|
|
||||||
|
### Mitra Pairing
|
||||||
|
- Add configuration on Control center to configure Mitra's app require to ping or not.
|
||||||
|
- When Control Center allow non ping, application or backend will not force mitra to ping and allow them to keep online even when the app is closed or in backround
|
||||||
|
- Modify Mitra Pairing confirmation to send notification through FCM when websocket to Mitra is closed
|
||||||
|
|
||||||
|
### Bi-Directional Chat (WebSocket + FCM)
|
||||||
|
|
||||||
|
#### Mitra App
|
||||||
|
- When there is new unread message, mitra app must shows badge on active session
|
||||||
|
- When there is new unread message, mitra app must shows badge on the chat active session inside active session page
|
||||||
|
- Unread badge on each active session will be cleared when the message has been read
|
||||||
|
- Unread badge on active session button on main page will be cleared when the message has been read
|
||||||
|
|
||||||
|
#### Customer App
|
||||||
|
- When there is new unread message, Customer app must shows badge on active session
|
||||||
|
- Unread badge will be cleared when unread message has been cleared
|
||||||
|
|
||||||
|
|
||||||
|
### Chat Closure & Extension
|
||||||
|
- When chat closure called, backend will send closure signal to both Mitra and Customer
|
||||||
|
- Backend will use FCM if the websocket connection is down
|
||||||
|
|
||||||
|
### Control Center
|
||||||
|
- Control center shows configuration for ping from mitra
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Riverpod Migration
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
- Migrate all BLoC classes in `client_app` and `mitra_app` to Riverpod annotation-based providers
|
||||||
|
- Replace `flutter_bloc` with `flutter_riverpod`, `riverpod_annotation`, `flutter_hooks`, and `hooks_riverpod`
|
||||||
|
- Add `riverpod_generator` + `build_runner` as dev dependencies for code generation
|
||||||
|
- No backend or control_center changes
|
||||||
|
|
||||||
|
### Migration Strategy
|
||||||
|
- [ ] Add Riverpod dependencies (`flutter_riverpod`, `hooks_riverpod`, `riverpod_annotation`) and dev dependencies (`riverpod_generator`, `build_runner`, `custom_lint`, `riverpod_lint`)
|
||||||
|
- [ ] Wrap app root with `ProviderScope`
|
||||||
|
- [ ] Migrate one Bloc at a time, starting with the simplest (e.g. AuthBloc)
|
||||||
|
- [ ] For each migrated Bloc:
|
||||||
|
1. Replace `Bloc`/`Cubit` class with `@riverpod` annotated `Notifier` or `AsyncNotifier` (extending `_$ClassName`)
|
||||||
|
2. Replace `BlocEvent` + `emit()` pattern with notifier methods that update `state` directly
|
||||||
|
3. Run `dart run build_runner build` to generate `.g.dart` files
|
||||||
|
4. Replace `BlocProvider` with generated provider (e.g. `authProvider`)
|
||||||
|
5. Replace `BlocBuilder` widgets with `ConsumerWidget` + `ref.watch()`
|
||||||
|
6. Replace `BlocListener` with `ref.listen()` inside widget or provider
|
||||||
|
7. Use `HookConsumerWidget` where flutter_hooks are needed (e.g. `useTextEditingController`, `useEffect`)
|
||||||
|
- [ ] Run E2E verification after each migration to catch regressions
|
||||||
|
- [ ] Remove `flutter_bloc` dependency only after all Blocs are migrated
|
||||||
|
|
||||||
|
### Affected Blocs
|
||||||
|
- [ ] `client_app` — AuthBloc, PairingBloc, ChatBloc, ChatOpeningBloc, SessionClosureBloc
|
||||||
|
- [ ] `mitra_app` — AuthBloc, OnlineStatusBloc, MitraChatBloc, ExtensionBloc
|
||||||
|
|
||||||
|
|
||||||
|
# Non-Functional Requirement
|
||||||
|
- [ ] WebSocket reconnects gracefully after network interruption (within 5s on stable network)
|
||||||
|
- [ ] Use FCM to send command or message when websocket is down
|
||||||
|
- [ ] No message loss during brief disconnects — undelivered messages sync on reconnect
|
||||||
|
- [ ] Chat screen maintains scroll position and input draft on app lifecycle events (background/foreground)
|
||||||
|
- [ ] Riverpod migration introduces zero new UI bugs — feature parity with BLoC implementation
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Tech Stack
|
||||||
|
- State management: Riverpod + flutter_hooks (replacing flutter_bloc)
|
||||||
|
- No backend changes expected — migration is Flutter-only
|
||||||
Reference in New Issue
Block a user