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:
2026-04-09 14:01:48 +08:00
parent d15b2f05fc
commit bc66bbf50a
12 changed files with 860 additions and 251 deletions

View 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();
}
}

View 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