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
|
||||
android:label="client_app"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
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_bloc/flutter_bloc.dart';
|
||||
import '../../../core/auth/auth_bloc.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/auth/auth_notifier.dart';
|
||||
|
||||
class DisplayNameScreen extends StatefulWidget {
|
||||
class DisplayNameScreen extends ConsumerStatefulWidget {
|
||||
const DisplayNameScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DisplayNameScreen> createState() => _DisplayNameScreenState();
|
||||
ConsumerState<DisplayNameScreen> createState() => _DisplayNameScreenState();
|
||||
}
|
||||
|
||||
class _DisplayNameScreenState extends State<DisplayNameScreen> {
|
||||
class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
|
||||
final _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
@@ -21,46 +21,46 @@ class _DisplayNameScreenState extends State<DisplayNameScreen> {
|
||||
void _submit() {
|
||||
final name = _controller.text.trim();
|
||||
if (name.isEmpty) return;
|
||||
context.read<AuthBloc>().add(AnonymousLoginRequested(name));
|
||||
ref.read(authProvider.notifier).loginAnonymous(name);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
if (state is AuthError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(title: const Text('Siapa namamu?')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text('Pilih nama yang ingin kamu gunakan. Nama ini tidak akan terlihat oleh siapapun selain mitra kamu.'),
|
||||
const SizedBox(height: 24),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nama panggilan',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _submit(),
|
||||
final authState = ref.watch(authProvider);
|
||||
final isLoading = authState is AsyncLoading;
|
||||
|
||||
ref.listen(authProvider, (prev, next) {
|
||||
if (next is AsyncError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
|
||||
}
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Siapa namamu?')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text('Pilih nama yang ingin kamu gunakan. Nama ini tidak akan terlihat oleh siapapun selain mitra kamu.'),
|
||||
const SizedBox(height: 24),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nama panggilan',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) => ElevatedButton(
|
||||
onPressed: state is AuthLoading ? null : _submit,
|
||||
child: state is AuthLoading
|
||||
? const CircularProgressIndicator()
|
||||
: const Text('Lanjut'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: isLoading ? null : _submit,
|
||||
child: isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: const Text('Lanjut'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/auth/auth_bloc.dart';
|
||||
import '../../../core/auth/auth_notifier.dart';
|
||||
|
||||
/// Shown when anonymity is disabled by admin.
|
||||
/// User must link their account. Display name is pre-filled.
|
||||
class ForceRegisterScreen extends StatefulWidget {
|
||||
class ForceRegisterScreen extends ConsumerStatefulWidget {
|
||||
const ForceRegisterScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ForceRegisterScreen> createState() => _ForceRegisterScreenState();
|
||||
ConsumerState<ForceRegisterScreen> createState() => _ForceRegisterScreenState();
|
||||
}
|
||||
|
||||
class _ForceRegisterScreenState extends State<ForceRegisterScreen> {
|
||||
class _ForceRegisterScreenState extends ConsumerState<ForceRegisterScreen> {
|
||||
final _phoneController = TextEditingController();
|
||||
|
||||
@override
|
||||
@@ -23,80 +23,77 @@ class _ForceRegisterScreenState extends State<ForceRegisterScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
if (state is AuthOtpSent) {
|
||||
context.push('/auth/otp', extra: _phoneController.text.trim());
|
||||
}
|
||||
if (state is AuthAuthenticated) {
|
||||
// After linking, link account to existing anonymous record
|
||||
context.read<AuthBloc>().add(LinkAccountRequested());
|
||||
}
|
||||
if (state is AuthError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(title: const Text('Verifikasi Akun')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'Untuk melanjutkan, kamu perlu mendaftarkan akun.',
|
||||
style: TextStyle(fontSize: 16),
|
||||
final authState = ref.watch(authProvider);
|
||||
final isLoading = authState is AsyncLoading;
|
||||
|
||||
ref.listen(authProvider, (prev, next) {
|
||||
final data = next.valueOrNull;
|
||||
if (data is AuthOtpSentData) {
|
||||
context.push('/auth/otp', extra: _phoneController.text.trim());
|
||||
}
|
||||
if (data is AuthAuthenticatedData) {
|
||||
// After social login succeeds, link account to existing anonymous record
|
||||
ref.read(authProvider.notifier).linkAccount();
|
||||
}
|
||||
if (next is AsyncError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
|
||||
}
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Verifikasi Akun')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'Untuk melanjutkan, kamu perlu mendaftarkan akun.',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.g_mobiledata),
|
||||
onPressed: isLoading ? null
|
||||
: () => ref.read(authProvider.notifier).loginGoogle(),
|
||||
label: const Text('Lanjut dengan Google'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.apple),
|
||||
onPressed: isLoading ? null
|
||||
: () => ref.read(authProvider.notifier).loginApple(),
|
||||
label: const Text('Lanjut dengan Apple'),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Row(children: [
|
||||
Expanded(child: Divider()),
|
||||
Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')),
|
||||
Expanded(child: Divider()),
|
||||
]),
|
||||
),
|
||||
TextField(
|
||||
controller: _phoneController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nomor HP',
|
||||
hintText: '+628xxxxxxxxxx',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) => ElevatedButton.icon(
|
||||
icon: const Icon(Icons.g_mobiledata),
|
||||
onPressed: state is AuthLoading ? null
|
||||
: () => context.read<AuthBloc>().add(GoogleLoginRequested()),
|
||||
label: const Text('Lanjut dengan Google'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) => ElevatedButton.icon(
|
||||
icon: const Icon(Icons.apple),
|
||||
onPressed: state is AuthLoading ? null
|
||||
: () => context.read<AuthBloc>().add(AppleLoginRequested()),
|
||||
label: const Text('Lanjut dengan Apple'),
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Row(children: [
|
||||
Expanded(child: Divider()),
|
||||
Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')),
|
||||
Expanded(child: Divider()),
|
||||
]),
|
||||
),
|
||||
TextField(
|
||||
controller: _phoneController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nomor HP',
|
||||
hintText: '+628xxxxxxxxxx',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) => ElevatedButton(
|
||||
onPressed: state is AuthLoading ? null : () {
|
||||
final phone = _phoneController.text.trim();
|
||||
if (phone.isEmpty) return;
|
||||
context.read<AuthBloc>().add(PhoneOtpRequested(phone));
|
||||
},
|
||||
child: state is AuthLoading
|
||||
? const CircularProgressIndicator()
|
||||
: const Text('Kirim OTP'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: isLoading ? null : () {
|
||||
final phone = _phoneController.text.trim();
|
||||
if (phone.isEmpty) return;
|
||||
ref.read(authProvider.notifier).requestOtp(phone);
|
||||
},
|
||||
child: isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: const Text('Kirim OTP'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/auth/auth_bloc.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/auth/auth_notifier.dart';
|
||||
|
||||
class OtpScreen extends StatefulWidget {
|
||||
class OtpScreen extends ConsumerStatefulWidget {
|
||||
final String phone;
|
||||
const OtpScreen({super.key, required this.phone});
|
||||
|
||||
@override
|
||||
State<OtpScreen> createState() => _OtpScreenState();
|
||||
ConsumerState<OtpScreen> createState() => _OtpScreenState();
|
||||
}
|
||||
|
||||
class _OtpScreenState extends State<OtpScreen> {
|
||||
class _OtpScreenState extends ConsumerState<OtpScreen> {
|
||||
final _otpController = TextEditingController();
|
||||
String? _verificationId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Capture verification ID from current state
|
||||
final data = ref.read(authProvider).valueOrNull;
|
||||
if (data is AuthOtpSentData) {
|
||||
_verificationId = data.verificationId;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -21,46 +32,51 @@ class _OtpScreenState extends State<OtpScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
if (state is AuthError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(title: const Text('Masukkan OTP')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Kode OTP telah dikirim ke ${widget.phone}'),
|
||||
const SizedBox(height: 24),
|
||||
TextField(
|
||||
controller: _otpController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Kode OTP',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
final authState = ref.watch(authProvider);
|
||||
final isLoading = authState is AsyncLoading;
|
||||
|
||||
// Update verification ID if state changes
|
||||
final data = authState.valueOrNull;
|
||||
if (data is AuthOtpSentData) {
|
||||
_verificationId = data.verificationId;
|
||||
}
|
||||
|
||||
ref.listen(authProvider, (prev, next) {
|
||||
if (next is AsyncError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
|
||||
}
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Masukkan OTP')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Kode OTP telah dikirim ke ${widget.phone}'),
|
||||
const SizedBox(height: 24),
|
||||
TextField(
|
||||
controller: _otpController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Kode OTP',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) => ElevatedButton(
|
||||
onPressed: state is AuthLoading ? null : () {
|
||||
final otp = _otpController.text.trim();
|
||||
if (otp.length != 6) return;
|
||||
final verificationId = (state is AuthOtpSent) ? state.verificationId : '';
|
||||
context.read<AuthBloc>().add(OtpVerified(verificationId, otp));
|
||||
},
|
||||
child: state is AuthLoading
|
||||
? const CircularProgressIndicator()
|
||||
: const Text('Verifikasi'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: isLoading ? null : () {
|
||||
final otp = _otpController.text.trim();
|
||||
if (otp.length != 6 || _verificationId == null) return;
|
||||
ref.read(authProvider.notifier).verifyOtp(_verificationId!, otp);
|
||||
},
|
||||
child: isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: const Text('Verifikasi'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/auth/auth_bloc.dart';
|
||||
import '../../../core/auth/auth_notifier.dart';
|
||||
|
||||
class RegisterScreen extends StatefulWidget {
|
||||
class RegisterScreen extends ConsumerStatefulWidget {
|
||||
const RegisterScreen({super.key});
|
||||
|
||||
@override
|
||||
State<RegisterScreen> createState() => _RegisterScreenState();
|
||||
ConsumerState<RegisterScreen> createState() => _RegisterScreenState();
|
||||
}
|
||||
|
||||
class _RegisterScreenState extends State<RegisterScreen> {
|
||||
class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
final _phoneController = TextEditingController();
|
||||
|
||||
@override
|
||||
@@ -21,71 +21,68 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
if (state is AuthOtpSent) {
|
||||
context.push('/auth/otp', extra: _phoneController.text.trim());
|
||||
}
|
||||
if (state is AuthError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(title: const Text('Masuk / Daftar')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) => ElevatedButton.icon(
|
||||
icon: const Icon(Icons.g_mobiledata),
|
||||
onPressed: state is AuthLoading ? null
|
||||
: () => context.read<AuthBloc>().add(GoogleLoginRequested()),
|
||||
label: const Text('Lanjut dengan Google'),
|
||||
),
|
||||
final authState = ref.watch(authProvider);
|
||||
final isLoading = authState is AsyncLoading;
|
||||
|
||||
ref.listen(authProvider, (prev, next) {
|
||||
final data = next.valueOrNull;
|
||||
if (data is AuthOtpSentData) {
|
||||
context.push('/auth/otp', extra: _phoneController.text.trim());
|
||||
}
|
||||
if (next is AsyncError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
|
||||
}
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Masuk / Daftar')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.g_mobiledata),
|
||||
onPressed: isLoading ? null
|
||||
: () => ref.read(authProvider.notifier).loginGoogle(),
|
||||
label: const Text('Lanjut dengan Google'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.apple),
|
||||
onPressed: isLoading ? null
|
||||
: () => ref.read(authProvider.notifier).loginApple(),
|
||||
label: const Text('Lanjut dengan Apple'),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Row(children: [
|
||||
Expanded(child: Divider()),
|
||||
Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')),
|
||||
Expanded(child: Divider()),
|
||||
]),
|
||||
),
|
||||
TextField(
|
||||
controller: _phoneController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nomor HP',
|
||||
hintText: '+628xxxxxxxxxx',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) => ElevatedButton.icon(
|
||||
icon: const Icon(Icons.apple),
|
||||
onPressed: state is AuthLoading ? null
|
||||
: () => context.read<AuthBloc>().add(AppleLoginRequested()),
|
||||
label: const Text('Lanjut dengan Apple'),
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Row(children: [
|
||||
Expanded(child: Divider()),
|
||||
Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')),
|
||||
Expanded(child: Divider()),
|
||||
]),
|
||||
),
|
||||
TextField(
|
||||
controller: _phoneController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nomor HP',
|
||||
hintText: '+628xxxxxxxxxx',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) => ElevatedButton(
|
||||
onPressed: state is AuthLoading ? null : () {
|
||||
final phone = _phoneController.text.trim();
|
||||
if (phone.isEmpty) return;
|
||||
context.read<AuthBloc>().add(PhoneOtpRequested(phone));
|
||||
},
|
||||
child: state is AuthLoading
|
||||
? const CircularProgressIndicator()
|
||||
: const Text('Kirim OTP'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: isLoading ? null : () {
|
||||
final phone = _phoneController.text.trim();
|
||||
if (phone.isEmpty) return;
|
||||
ref.read(authProvider.notifier).requestOtp(phone);
|
||||
},
|
||||
child: isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: const Text('Kirim OTP'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/api/api_client.dart';
|
||||
import '../../../core/chat/chat_opening_bloc.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/chat/chat_opening_provider.dart';
|
||||
import '../../../core/chat/session_closure_bloc.dart';
|
||||
import '../../../core/pairing/pairing_bloc.dart';
|
||||
|
||||
class PricingBottomSheet extends StatelessWidget {
|
||||
class PricingBottomSheet extends ConsumerWidget {
|
||||
/// If set, the bottom sheet is in "extension" mode — selecting a tier extends the session.
|
||||
final String? extensionSessionId;
|
||||
|
||||
@@ -16,14 +16,11 @@ class PricingBottomSheet extends StatelessWidget {
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => BlocProvider(
|
||||
create: (ctx) => ChatOpeningBloc(apiClient: ctx.read<ApiClient>())..add(LoadPricing()),
|
||||
child: MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: context.read<PairingBloc>()),
|
||||
],
|
||||
child: const PricingBottomSheet(),
|
||||
),
|
||||
builder: (_) => MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: context.read<PairingBloc>()),
|
||||
],
|
||||
child: const PricingBottomSheet(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -33,14 +30,11 @@ class PricingBottomSheet extends StatelessWidget {
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => BlocProvider(
|
||||
create: (ctx) => ChatOpeningBloc(apiClient: ctx.read<ApiClient>())..add(LoadPricing()),
|
||||
child: MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: context.read<SessionClosureBloc>()),
|
||||
],
|
||||
child: PricingBottomSheet(extensionSessionId: sessionId),
|
||||
),
|
||||
builder: (_) => MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: context.read<SessionClosureBloc>()),
|
||||
],
|
||||
child: PricingBottomSheet(extensionSessionId: sessionId),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -56,94 +50,83 @@ class PricingBottomSheet extends StatelessWidget {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isExtension = extensionSessionId != null;
|
||||
final pricingAsync = ref.watch(chatPricingProvider);
|
||||
|
||||
return BlocBuilder<ChatOpeningBloc, ChatOpeningState>(
|
||||
builder: (context, state) {
|
||||
if (state is PricingLoading || state is PricingInitial) {
|
||||
return const SizedBox(
|
||||
height: 200,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is PricingError) {
|
||||
return SizedBox(
|
||||
height: 200,
|
||||
child: Center(child: Text(state.message)),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is PricingLoaded) {
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.6,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.8,
|
||||
expand: false,
|
||||
builder: (_, scrollController) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ListView(
|
||||
controller: scrollController,
|
||||
children: [
|
||||
Text(
|
||||
isExtension ? 'Perpanjang Durasi' : 'Pilih Durasi Curhat',
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (!isExtension && state.freeTrialEligible) ...[
|
||||
Card(
|
||||
color: Colors.green.shade50,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.card_giftcard, color: Colors.green),
|
||||
title: Text('Free Trial (${state.freeTrialDurationMinutes} Menit)'),
|
||||
subtitle: const Text('Gratis untuk pertama kali!'),
|
||||
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
_startPairing(context, isFreeTrial: true);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Divider(height: 24),
|
||||
],
|
||||
...state.tiers.map((tier) => Card(
|
||||
child: ListTile(
|
||||
title: Text(tier.label),
|
||||
trailing: Text(
|
||||
_formatPrice(tier.price),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
if (isExtension) {
|
||||
_requestExtension(
|
||||
context,
|
||||
sessionId: extensionSessionId!,
|
||||
durationMinutes: tier.durationMinutes,
|
||||
price: tier.price,
|
||||
);
|
||||
} else {
|
||||
_startPairing(
|
||||
context,
|
||||
durationMinutes: tier.durationMinutes,
|
||||
price: tier.price,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
)),
|
||||
],
|
||||
return pricingAsync.when(
|
||||
loading: () => const SizedBox(
|
||||
height: 200,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (error, _) => SizedBox(
|
||||
height: 200,
|
||||
child: Center(child: Text('Gagal memuat harga. Coba lagi.')),
|
||||
),
|
||||
data: (pricing) => DraggableScrollableSheet(
|
||||
initialChildSize: 0.6,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.8,
|
||||
expand: false,
|
||||
builder: (_, scrollController) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ListView(
|
||||
controller: scrollController,
|
||||
children: [
|
||||
Text(
|
||||
isExtension ? 'Perpanjang Durasi' : 'Pilih Durasi Curhat',
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
},
|
||||
const SizedBox(height: 16),
|
||||
if (!isExtension && pricing.freeTrialEligible) ...[
|
||||
Card(
|
||||
color: Colors.green.shade50,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.card_giftcard, color: Colors.green),
|
||||
title: Text('Free Trial (${pricing.freeTrialDurationMinutes} Menit)'),
|
||||
subtitle: const Text('Gratis untuk pertama kali!'),
|
||||
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
_startPairing(context, isFreeTrial: true);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Divider(height: 24),
|
||||
],
|
||||
...pricing.tiers.map((tier) => Card(
|
||||
child: ListTile(
|
||||
title: Text(tier.label),
|
||||
trailing: Text(
|
||||
_formatPrice(tier.price),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
if (isExtension) {
|
||||
_requestExtension(
|
||||
context,
|
||||
sessionId: extensionSessionId!,
|
||||
durationMinutes: tier.durationMinutes,
|
||||
price: tier.price,
|
||||
);
|
||||
} else {
|
||||
_startPairing(
|
||||
context,
|
||||
durationMinutes: tier.durationMinutes,
|
||||
price: tier.price,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/auth/auth_bloc.dart';
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../../core/auth/auth_notifier.dart';
|
||||
import '../../core/api/api_client_provider.dart';
|
||||
import '../../core/pairing/pairing_bloc.dart';
|
||||
import '../chat/widgets/pricing_bottom_sheet.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
class HomeScreen extends ConsumerStatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
ConsumerState<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
|
||||
class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObserver {
|
||||
Map<String, dynamic>? _activeSession;
|
||||
bool _loadingSession = true;
|
||||
|
||||
@@ -40,13 +41,12 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// Re-check when navigating back to this screen
|
||||
_checkActiveSession();
|
||||
}
|
||||
|
||||
Future<void> _checkActiveSession() async {
|
||||
try {
|
||||
final apiClient = context.read<ApiClient>();
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
final response = await apiClient.get('/api/client/chat/session/active');
|
||||
final data = response['data'];
|
||||
if (mounted) {
|
||||
@@ -62,6 +62,15 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authState = ref.watch(authProvider);
|
||||
final authData = authState.valueOrNull;
|
||||
|
||||
final displayName = switch (authData) {
|
||||
AuthAuthenticatedData d => d.profile['display_name'] as String? ?? '',
|
||||
AuthAnonymousData d => d.displayName,
|
||||
_ => '',
|
||||
};
|
||||
|
||||
return BlocListener<PairingBloc, PairingState>(
|
||||
listener: (context, state) {
|
||||
if (state is PairingSearching) {
|
||||
@@ -74,63 +83,53 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
|
||||
);
|
||||
}
|
||||
},
|
||||
child: BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
final displayName = state is AuthAuthenticated
|
||||
? state.profile['display_name'] as String
|
||||
: state is AuthAnonymous
|
||||
? state.displayName
|
||||
: '';
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Halo Bestie'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.history),
|
||||
onPressed: () => context.push('/chat/history'),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout),
|
||||
onPressed: () => context.read<AuthBloc>().add(LogoutRequested()),
|
||||
),
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Halo Bestie'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.history),
|
||||
onPressed: () => context.push('/chat/history'),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout),
|
||||
onPressed: () => ref.read(authProvider.notifier).logout(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)),
|
||||
const SizedBox(height: 32),
|
||||
if (_loadingSession)
|
||||
const CircularProgressIndicator()
|
||||
else if (_activeSession != null)
|
||||
_ActiveSessionCard(
|
||||
session: _activeSession!,
|
||||
onTap: () {
|
||||
final sessionId = _activeSession!['id'] as String;
|
||||
final mitraName = _activeSession!['mitra_display_name'] as String? ?? 'Bestie';
|
||||
context.push('/chat/session/$sessionId', extra: mitraName);
|
||||
},
|
||||
)
|
||||
else ...[
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16),
|
||||
),
|
||||
onPressed: () => PricingBottomSheet.show(context),
|
||||
child: const Text('Mulai Curhat', style: TextStyle(fontSize: 18)),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)),
|
||||
const SizedBox(height: 32),
|
||||
if (_loadingSession)
|
||||
const CircularProgressIndicator()
|
||||
else if (_activeSession != null)
|
||||
_ActiveSessionCard(
|
||||
session: _activeSession!,
|
||||
onTap: () {
|
||||
final sessionId = _activeSession!['id'] as String;
|
||||
final mitraName = _activeSession!['mitra_display_name'] as String? ?? 'Bestie';
|
||||
context.push('/chat/session/$sessionId', extra: mitraName);
|
||||
},
|
||||
)
|
||||
else ...[
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16),
|
||||
),
|
||||
onPressed: () => PricingBottomSheet.show(context),
|
||||
child: const Text('Mulai Curhat', style: TextStyle(fontSize: 18)),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'core/api/api_client.dart';
|
||||
import 'core/auth/auth_bloc.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'core/api/api_client_provider.dart';
|
||||
import 'core/auth/auth_notifier.dart';
|
||||
import 'core/chat/chat_bloc.dart';
|
||||
import 'core/chat/session_closure_bloc.dart';
|
||||
import 'core/pairing/pairing_bloc.dart';
|
||||
@@ -16,68 +16,64 @@ void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||
|
||||
// Request notification permission
|
||||
final messaging = FirebaseMessaging.instance;
|
||||
await messaging.requestPermission();
|
||||
|
||||
runApp(const App());
|
||||
runApp(const ProviderScope(child: App()));
|
||||
}
|
||||
|
||||
class App extends StatefulWidget {
|
||||
class App extends ConsumerStatefulWidget {
|
||||
const App({super.key});
|
||||
|
||||
@override
|
||||
State<App> createState() => _AppState();
|
||||
ConsumerState<App> createState() => _AppState();
|
||||
}
|
||||
|
||||
class _AppState extends State<App> {
|
||||
final _apiClient = ApiClient();
|
||||
late final AuthBloc _authBloc;
|
||||
late final GoRouter _router;
|
||||
class _AppState extends ConsumerState<App> {
|
||||
bool _fcmRegistered = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_authBloc = AuthBloc(apiClient: _apiClient)..add(AppStarted());
|
||||
_router = buildRouter(_authBloc);
|
||||
NotificationService.initialize(_router);
|
||||
_registerFcmToken();
|
||||
}
|
||||
|
||||
Future<void> _registerFcmToken() async {
|
||||
// Listen for auth state, then register token
|
||||
_authBloc.stream.listen((state) async {
|
||||
if (state is AuthAuthenticated || state is AuthAnonymous) {
|
||||
try {
|
||||
final token = await FirebaseMessaging.instance.getToken();
|
||||
if (token != null) {
|
||||
await _apiClient.post('/api/shared/device-token', data: {'token': token});
|
||||
}
|
||||
} catch (_) {}
|
||||
void _registerFcmToken() {
|
||||
if (_fcmRegistered) return;
|
||||
_fcmRegistered = true;
|
||||
Future(() async {
|
||||
try {
|
||||
final token = await FirebaseMessaging.instance.getToken();
|
||||
if (token != null) {
|
||||
await ref.read(apiClientProvider).post('/api/shared/device-token', data: {'token': token});
|
||||
}
|
||||
} catch (_) {
|
||||
_fcmRegistered = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_authBloc.close();
|
||||
_router.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Listen for auth changes to register FCM token
|
||||
ref.listen(authProvider, (prev, next) {
|
||||
final data = next.valueOrNull;
|
||||
if (data is AuthAuthenticatedData || data is AuthAnonymousData) {
|
||||
_registerFcmToken();
|
||||
}
|
||||
});
|
||||
|
||||
final router = ref.watch(routerProvider);
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
|
||||
// Initialize notifications once router is available
|
||||
NotificationService.initialize(router);
|
||||
|
||||
// Keep BlocProviders for non-migrated blocs (will be removed as they're migrated)
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: _authBloc),
|
||||
BlocProvider(create: (_) => PairingBloc(apiClient: _apiClient)),
|
||||
BlocProvider(create: (_) => ChatBloc(apiClient: _apiClient)),
|
||||
BlocProvider(create: (_) => SessionClosureBloc(apiClient: _apiClient)),
|
||||
RepositoryProvider.value(value: _apiClient),
|
||||
BlocProvider(create: (_) => PairingBloc(apiClient: apiClient)),
|
||||
BlocProvider(create: (_) => ChatBloc(apiClient: apiClient)),
|
||||
BlocProvider(create: (_) => SessionClosureBloc(apiClient: apiClient)),
|
||||
RepositoryProvider.value(value: apiClient),
|
||||
],
|
||||
child: MaterialApp.router(
|
||||
title: 'Halo Bestie',
|
||||
routerConfig: _router,
|
||||
routerConfig: router,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'core/auth/auth_bloc.dart';
|
||||
import 'core/auth/auth_notifier.dart';
|
||||
import 'features/auth/screens/welcome_screen.dart';
|
||||
import 'features/auth/screens/display_name_screen.dart';
|
||||
import 'features/auth/screens/register_screen.dart';
|
||||
@@ -16,38 +16,43 @@ import 'features/chat/screens/chat_screen.dart';
|
||||
import 'features/chat/screens/chat_history_screen.dart';
|
||||
import 'features/chat/screens/chat_transcript_screen.dart';
|
||||
|
||||
/// Converts a BLoC stream into a ChangeNotifier for GoRouter's refreshListenable.
|
||||
class _BlocRefreshNotifier extends ChangeNotifier {
|
||||
late final StreamSubscription _subscription;
|
||||
class RouterNotifier extends ChangeNotifier {
|
||||
final Ref _ref;
|
||||
|
||||
_BlocRefreshNotifier(AuthBloc bloc) {
|
||||
_subscription = bloc.stream.listen((_) => notifyListeners());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription.cancel();
|
||||
super.dispose();
|
||||
RouterNotifier(this._ref) {
|
||||
_ref.listen(authProvider, (_, __) => notifyListeners());
|
||||
}
|
||||
}
|
||||
|
||||
GoRouter buildRouter(AuthBloc authBloc) {
|
||||
final routerProvider = Provider<GoRouter>((ref) => buildRouter(ref));
|
||||
|
||||
GoRouter buildRouter(Ref ref) {
|
||||
final notifier = RouterNotifier(ref);
|
||||
|
||||
return GoRouter(
|
||||
initialLocation: '/splash',
|
||||
refreshListenable: _BlocRefreshNotifier(authBloc),
|
||||
refreshListenable: notifier,
|
||||
redirect: (context, state) {
|
||||
final authState = authBloc.state;
|
||||
final authState = ref.read(authProvider);
|
||||
final isSplash = state.matchedLocation == '/splash';
|
||||
final isAuthRoute = state.matchedLocation.startsWith('/auth') ||
|
||||
state.matchedLocation == '/welcome';
|
||||
|
||||
// Show splash while loading
|
||||
if (authState is AuthLoading) return isSplash ? null : '/splash';
|
||||
if (authState is AsyncLoading) return isSplash ? null : '/splash';
|
||||
|
||||
if (authState is AuthAuthenticated || authState is AuthAnonymous) {
|
||||
final data = authState.valueOrNull;
|
||||
if (data == null) {
|
||||
// Error state — show login
|
||||
if (!isAuthRoute && !isSplash) return '/welcome';
|
||||
if (isSplash) return '/welcome';
|
||||
return null;
|
||||
}
|
||||
|
||||
if (data is AuthAuthenticatedData || data is AuthAnonymousData) {
|
||||
return (isSplash || isAuthRoute) ? '/home' : null;
|
||||
}
|
||||
if (authState is AuthForceRegister) return '/auth/force-register';
|
||||
if (data is AuthForceRegisterData) return '/auth/force-register';
|
||||
if (!isAuthRoute && !isSplash) return '/welcome';
|
||||
if (isSplash) return '/welcome';
|
||||
return null;
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
_fe_analyzer_shared:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "85.0.0"
|
||||
_flutterfire_internals:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -9,6 +17,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.35"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.6.0"
|
||||
analyzer_plugin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer_plugin
|
||||
sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.4"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -41,6 +65,70 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
build:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build
|
||||
sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
build_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_config
|
||||
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
build_daemon:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_daemon
|
||||
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.1"
|
||||
build_resolvers:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_resolvers
|
||||
sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.4"
|
||||
build_runner:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: build_runner
|
||||
sha256: "74691599a5bc750dc96a6b4bfd48f7d9d66453eab04c7f4063134800d6a5c573"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.14"
|
||||
build_runner_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_runner_core
|
||||
sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.0.0"
|
||||
built_collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: built_collection
|
||||
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.1"
|
||||
built_value:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: built_value
|
||||
sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.12.5"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -49,6 +137,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: checked_yaml
|
||||
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.4"
|
||||
ci:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ci
|
||||
sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.0"
|
||||
cli_util:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cli_util
|
||||
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.2"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -57,6 +169,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_builder:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_builder
|
||||
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.11.1"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -65,6 +185,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: convert
|
||||
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -73,6 +201,46 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
custom_lint:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: custom_lint
|
||||
sha256: "9656925637516c5cf0f5da018b33df94025af2088fe09c8ae2ca54c53f2d9a84"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.6"
|
||||
custom_lint_builder:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: custom_lint_builder
|
||||
sha256: "6cdc8e87e51baaaba9c43e283ed8d28e59a0c4732279df62f66f7b5984655414"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.6"
|
||||
custom_lint_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: custom_lint_core
|
||||
sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.5"
|
||||
custom_lint_visitor:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: custom_lint_visitor
|
||||
sha256: "4a86a0d8415a91fbb8298d6ef03e9034dc8e323a599ddc4120a0e36c433983a2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0+7.7.0"
|
||||
dart_style:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_style
|
||||
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -201,6 +369,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.8.7"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -214,6 +390,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.6"
|
||||
flutter_hooks:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_hooks
|
||||
sha256: cde36b12f7188c85286fba9b38cc5a902e7279f36dd676967106c041dc9dde70
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.20.5"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -254,6 +438,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
flutter_riverpod:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_riverpod
|
||||
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -264,6 +456,30 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
freezed_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: freezed_annotation
|
||||
sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
frontend_server_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: frontend_server_client
|
||||
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: glob
|
||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
go_router:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -320,6 +536,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.4+4"
|
||||
graphs:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: graphs
|
||||
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
hooks_riverpod:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: hooks_riverpod
|
||||
sha256: "70bba33cfc5670c84b796e6929c54b8bc5be7d0fe15bb28c2560500b9ad06966"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
hotreloader:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hotreloader
|
||||
sha256: "66871df468fc24eee81f1a0a7cb98acc104716f9b7376d355437b48d633c4ebf"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.4.0"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -328,6 +568,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_multi_server:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_multi_server
|
||||
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -336,6 +584,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
io:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: io
|
||||
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: json_annotation
|
||||
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.11.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -416,6 +688,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_config
|
||||
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -472,6 +752,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pool:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pool
|
||||
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.2"
|
||||
provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -480,6 +768,70 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.5+1"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
pubspec_parse:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pubspec_parse
|
||||
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
riverpod:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: riverpod
|
||||
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
riverpod_analyzer_utils:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: riverpod_analyzer_utils
|
||||
sha256: "03a17170088c63aab6c54c44456f5ab78876a1ddb6032ffde1662ddab4959611"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.10"
|
||||
riverpod_annotation:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: riverpod_annotation
|
||||
sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
riverpod_generator:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: riverpod_generator
|
||||
sha256: "44a0992d54473eb199ede00e2260bd3c262a86560e3c6f6374503d86d0580e36"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.5"
|
||||
riverpod_lint:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: riverpod_lint
|
||||
sha256: "89a52b7334210dbff8605c3edf26cfe69b15062beed5cbfeff2c3812c33c9e35"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.5"
|
||||
rxdart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: rxdart
|
||||
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.28.0"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -536,6 +888,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shelf:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf
|
||||
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.2"
|
||||
shelf_web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_web_socket
|
||||
sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
sign_in_with_apple:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -565,6 +933,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_gen:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_gen
|
||||
sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -581,6 +957,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
state_notifier:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: state_notifier
|
||||
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -589,6 +973,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
stream_transform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_transform
|
||||
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -621,6 +1013,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.0"
|
||||
timing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timing
|
||||
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -629,6 +1029,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.3"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -645,6 +1053,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
watcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: watcher
|
||||
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -677,6 +1093,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.10.0 <4.0.0"
|
||||
flutter: ">=3.38.1"
|
||||
|
||||
@@ -27,6 +27,10 @@ dependencies:
|
||||
# State management
|
||||
flutter_bloc: ^8.1.5
|
||||
equatable: ^2.0.5
|
||||
flutter_riverpod: ^2.6.1
|
||||
hooks_riverpod: ^2.6.1
|
||||
riverpod_annotation: ^2.6.1
|
||||
flutter_hooks: ^0.20.5
|
||||
|
||||
# Storage
|
||||
shared_preferences: ^2.2.3
|
||||
@@ -39,6 +43,10 @@ dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^3.0.0
|
||||
riverpod_generator: ^2.6.2
|
||||
build_runner: ^2.4.13
|
||||
custom_lint: ^0.7.0
|
||||
riverpod_lint: ^2.6.2
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
<application
|
||||
android:label="mitra_app"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
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:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'core/api/api_client.dart';
|
||||
import 'core/auth/auth_bloc.dart';
|
||||
@@ -20,7 +21,7 @@ void main() async {
|
||||
final messaging = FirebaseMessaging.instance;
|
||||
await messaging.requestPermission();
|
||||
|
||||
runApp(const App());
|
||||
runApp(const ProviderScope(child: App()));
|
||||
}
|
||||
|
||||
class App extends StatefulWidget {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
_fe_analyzer_shared:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "85.0.0"
|
||||
_flutterfire_internals:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -9,6 +17,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.35"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.6.0"
|
||||
analyzer_plugin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer_plugin
|
||||
sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.4"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -41,6 +65,70 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
build:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build
|
||||
sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
build_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_config
|
||||
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
build_daemon:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_daemon
|
||||
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.1"
|
||||
build_resolvers:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_resolvers
|
||||
sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.4"
|
||||
build_runner:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: build_runner
|
||||
sha256: "74691599a5bc750dc96a6b4bfd48f7d9d66453eab04c7f4063134800d6a5c573"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.14"
|
||||
build_runner_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_runner_core
|
||||
sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.0.0"
|
||||
built_collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: built_collection
|
||||
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.1"
|
||||
built_value:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: built_value
|
||||
sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.12.5"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -49,6 +137,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: checked_yaml
|
||||
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.4"
|
||||
ci:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ci
|
||||
sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.0"
|
||||
cli_util:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cli_util
|
||||
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.2"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -57,6 +169,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_builder:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_builder
|
||||
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.11.1"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -65,6 +185,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: convert
|
||||
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -73,6 +201,46 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
custom_lint:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: custom_lint
|
||||
sha256: "9656925637516c5cf0f5da018b33df94025af2088fe09c8ae2ca54c53f2d9a84"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.6"
|
||||
custom_lint_builder:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: custom_lint_builder
|
||||
sha256: "6cdc8e87e51baaaba9c43e283ed8d28e59a0c4732279df62f66f7b5984655414"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.6"
|
||||
custom_lint_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: custom_lint_core
|
||||
sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.5"
|
||||
custom_lint_visitor:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: custom_lint_visitor
|
||||
sha256: "4a86a0d8415a91fbb8298d6ef03e9034dc8e323a599ddc4120a0e36c433983a2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0+7.7.0"
|
||||
dart_style:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_style
|
||||
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -121,6 +289,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
firebase_auth:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -193,6 +369,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.8.7"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -206,6 +390,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.6"
|
||||
flutter_hooks:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_hooks
|
||||
sha256: cde36b12f7188c85286fba9b38cc5a902e7279f36dd676967106c041dc9dde70
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.20.5"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -246,6 +438,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
flutter_riverpod:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_riverpod
|
||||
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -256,6 +456,30 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
freezed_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: freezed_annotation
|
||||
sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
frontend_server_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: frontend_server_client
|
||||
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: glob
|
||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
go_router:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -264,6 +488,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "13.2.5"
|
||||
graphs:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: graphs
|
||||
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
hooks_riverpod:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: hooks_riverpod
|
||||
sha256: "70bba33cfc5670c84b796e6929c54b8bc5be7d0fe15bb28c2560500b9ad06966"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
hotreloader:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hotreloader
|
||||
sha256: "66871df468fc24eee81f1a0a7cb98acc104716f9b7376d355437b48d633c4ebf"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.4.0"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -272,6 +520,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_multi_server:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_multi_server
|
||||
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -280,6 +536,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
io:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: io
|
||||
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: json_annotation
|
||||
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.11.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -360,6 +640,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_config
|
||||
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -384,6 +672,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pool:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pool
|
||||
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.2"
|
||||
provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -392,11 +688,99 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.5+1"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
pubspec_parse:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pubspec_parse
|
||||
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
riverpod:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: riverpod
|
||||
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
riverpod_analyzer_utils:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: riverpod_analyzer_utils
|
||||
sha256: "03a17170088c63aab6c54c44456f5ab78876a1ddb6032ffde1662ddab4959611"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.10"
|
||||
riverpod_annotation:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: riverpod_annotation
|
||||
sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
riverpod_generator:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: riverpod_generator
|
||||
sha256: "44a0992d54473eb199ede00e2260bd3c262a86560e3c6f6374503d86d0580e36"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.5"
|
||||
riverpod_lint:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: riverpod_lint
|
||||
sha256: "89a52b7334210dbff8605c3edf26cfe69b15062beed5cbfeff2c3812c33c9e35"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.5"
|
||||
rxdart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: rxdart
|
||||
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.28.0"
|
||||
shelf:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf
|
||||
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.2"
|
||||
shelf_web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_web_socket
|
||||
sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_gen:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_gen
|
||||
sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -413,6 +797,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
state_notifier:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: state_notifier
|
||||
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -421,6 +813,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
stream_transform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_transform
|
||||
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -453,6 +853,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.0"
|
||||
timing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timing
|
||||
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -461,6 +869,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.3"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -477,6 +893,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
watcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: watcher
|
||||
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -509,6 +933,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.10.0 <4.0.0"
|
||||
flutter: ">=3.38.1"
|
||||
|
||||
@@ -23,6 +23,10 @@ dependencies:
|
||||
# State management
|
||||
flutter_bloc: ^8.1.5
|
||||
equatable: ^2.0.5
|
||||
flutter_riverpod: ^2.6.1
|
||||
hooks_riverpod: ^2.6.1
|
||||
riverpod_annotation: ^2.6.1
|
||||
flutter_hooks: ^0.20.5
|
||||
|
||||
# Navigation
|
||||
go_router: ^13.2.1
|
||||
@@ -32,6 +36,10 @@ dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^3.0.0
|
||||
riverpod_generator: ^2.6.2
|
||||
build_runner: ^2.4.13
|
||||
custom_lint: ^0.7.0
|
||||
riverpod_lint: ^2.6.2
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
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