Phase 3.1: Complete client_app Riverpod migration (all blocs)
- Migrate SessionClosureBloc → SessionClosureNotifier (@riverpod) - Migrate PairingBloc → PairingNotifier (@riverpod, WebSocket + timer) - Migrate ChatBloc → ChatNotifier (@riverpod, WebSocket + message state) - Remove all flutter_bloc usage from client_app screens and main.dart - MultiBlocProvider fully removed from client_app - All screens now use ConsumerWidget/ConsumerStatefulWidget + ref Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
183
client_app/lib/core/pairing/pairing_notifier.dart
Normal file
183
client_app/lib/core/pairing/pairing_notifier.dart
Normal file
@@ -0,0 +1,183 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
import '../api/api_client.dart';
|
||||
import '../api/api_client_provider.dart';
|
||||
import '../constants.dart';
|
||||
|
||||
part 'pairing_notifier.g.dart';
|
||||
|
||||
// States
|
||||
sealed class PairingData {
|
||||
const PairingData();
|
||||
}
|
||||
|
||||
class PairingInitialData extends PairingData {
|
||||
const PairingInitialData();
|
||||
}
|
||||
|
||||
class PairingSearchingData extends PairingData {
|
||||
final String sessionId;
|
||||
const PairingSearchingData(this.sessionId);
|
||||
}
|
||||
|
||||
class PairingBestieFoundData extends PairingData {
|
||||
final String sessionId;
|
||||
final String mitraName;
|
||||
const PairingBestieFoundData({required this.sessionId, required this.mitraName});
|
||||
}
|
||||
|
||||
class PairingActiveData extends PairingData {
|
||||
final String sessionId;
|
||||
final String mitraName;
|
||||
const PairingActiveData({required this.sessionId, required this.mitraName});
|
||||
}
|
||||
|
||||
class PairingNoBestieData extends PairingData {
|
||||
const PairingNoBestieData();
|
||||
}
|
||||
|
||||
class PairingCancelledData extends PairingData {
|
||||
const PairingCancelledData();
|
||||
}
|
||||
|
||||
class PairingErrorData extends PairingData {
|
||||
final String message;
|
||||
const PairingErrorData(this.message);
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class Pairing extends _$Pairing {
|
||||
Timer? _timeoutTimer;
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription? _wsSubscription;
|
||||
|
||||
ApiClient get _apiClient => ref.read(apiClientProvider);
|
||||
|
||||
@override
|
||||
PairingData build() => const PairingInitialData();
|
||||
|
||||
Future<void> requestPairing() async {
|
||||
await _doPairingRequest({});
|
||||
}
|
||||
|
||||
Future<void> requestPairingWithTier({int? durationMinutes, int? price, bool isFreeTrial = false}) async {
|
||||
final body = <String, dynamic>{};
|
||||
if (isFreeTrial) {
|
||||
body['is_free_trial'] = true;
|
||||
} else {
|
||||
body['duration_minutes'] = durationMinutes;
|
||||
body['price'] = price;
|
||||
}
|
||||
await _doPairingRequest(body);
|
||||
}
|
||||
|
||||
Future<void> _doPairingRequest(Map<String, dynamic> body) async {
|
||||
if (state is! PairingInitialData) {
|
||||
state = const PairingInitialData();
|
||||
}
|
||||
try {
|
||||
await _connectWebSocket();
|
||||
|
||||
final response = await _apiClient.post('/api/client/chat/request', data: body);
|
||||
final data = response['data'] as Map<String, dynamic>;
|
||||
final sessionId = data['id'] as String;
|
||||
|
||||
state = PairingSearchingData(sessionId);
|
||||
|
||||
_timeoutTimer = Timer(const Duration(seconds: 60), () {
|
||||
_cleanup();
|
||||
state = const PairingNoBestieData();
|
||||
});
|
||||
} on DioException catch (e) {
|
||||
_cleanup();
|
||||
final code = e.response?.data?['error']?['code'];
|
||||
if (code == 'NO_MITRA_AVAILABLE') {
|
||||
state = const PairingNoBestieData();
|
||||
} else if (code == 'ALREADY_ACTIVE') {
|
||||
state = const PairingErrorData('Kamu sudah memiliki sesi aktif.');
|
||||
} else if (code == 'FREE_TRIAL_INELIGIBLE') {
|
||||
state = const PairingErrorData('Kamu tidak memenuhi syarat untuk free trial.');
|
||||
} else {
|
||||
state = const PairingErrorData('Gagal memulai. Coba lagi.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connectWebSocket() async {
|
||||
_closeWebSocket();
|
||||
final user = FirebaseAuth.instance.currentUser;
|
||||
if (user == null) return;
|
||||
|
||||
final token = await user.getIdToken();
|
||||
final wsUrl = ApiClient.baseUrl
|
||||
.replaceFirst('https://', 'wss://')
|
||||
.replaceFirst('http://', 'ws://');
|
||||
|
||||
_channel = WebSocketChannel.connect(Uri.parse('$wsUrl/api/shared/ws'));
|
||||
|
||||
_wsSubscription = _channel!.stream.listen(
|
||||
(raw) {
|
||||
final data = jsonDecode(raw as String) as Map<String, dynamic>;
|
||||
if (data['type'] == WsMessage.authOk) return;
|
||||
_onStatusUpdate(data);
|
||||
},
|
||||
onError: (_) {},
|
||||
onDone: () {},
|
||||
);
|
||||
|
||||
_channel!.sink.add(jsonEncode({
|
||||
'type': WsMessage.auth,
|
||||
'token': token,
|
||||
}));
|
||||
}
|
||||
|
||||
Future<void> _onStatusUpdate(Map<String, dynamic> data) async {
|
||||
final type = data['type'] as String?;
|
||||
|
||||
if (type == WsMessage.paired) {
|
||||
_cleanup();
|
||||
final mitraName = data['mitra_display_name'] as String? ?? 'Bestie';
|
||||
final sessionId = data['session_id'] as String;
|
||||
state = PairingBestieFoundData(sessionId: sessionId, mitraName: mitraName);
|
||||
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
state = PairingActiveData(sessionId: sessionId, mitraName: mitraName);
|
||||
} else if (type == SessionStatus.expired) {
|
||||
_cleanup();
|
||||
state = const PairingNoBestieData();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelPairing() async {
|
||||
if (state is PairingSearchingData) {
|
||||
final sessionId = (state as PairingSearchingData).sessionId;
|
||||
try {
|
||||
await _apiClient.post('/api/client/chat/request/$sessionId/cancel');
|
||||
} catch (_) {}
|
||||
_cleanup();
|
||||
state = const PairingCancelledData();
|
||||
}
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_cleanup();
|
||||
state = const PairingInitialData();
|
||||
}
|
||||
|
||||
void _closeWebSocket() {
|
||||
_wsSubscription?.cancel();
|
||||
_wsSubscription = null;
|
||||
_channel?.sink.close();
|
||||
_channel = null;
|
||||
}
|
||||
|
||||
void _cleanup() {
|
||||
_timeoutTimer?.cancel();
|
||||
_timeoutTimer = null;
|
||||
_closeWebSocket();
|
||||
}
|
||||
}
|
||||
24
client_app/lib/core/pairing/pairing_notifier.g.dart
Normal file
24
client_app/lib/core/pairing/pairing_notifier.g.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'pairing_notifier.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$pairingHash() => r'93049804c1d55a0195a56b97d6e7f34fe6ab8086';
|
||||
|
||||
/// See also [Pairing].
|
||||
@ProviderFor(Pairing)
|
||||
final pairingProvider = NotifierProvider<Pairing, PairingData>.internal(
|
||||
Pairing.new,
|
||||
name: r'pairingProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$pairingHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$Pairing = Notifier<PairingData>;
|
||||
// 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
|
||||
Reference in New Issue
Block a user