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,332 @@
import 'dart:async';
import 'dart:convert';
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 'chat_notifier.g.dart';
// States
sealed class ChatData {
const ChatData();
}
class ChatInitialData extends ChatData {
const ChatInitialData();
}
class ChatConnectingData extends ChatData {
const ChatConnectingData();
}
class ChatConnectedData extends ChatData {
final List<ChatMessage> messages;
final bool isOtherTyping;
final int? remainingSeconds;
final bool sessionExpired;
final bool sessionPaused;
final bool sessionClosing;
final Map<String, dynamic>? extensionResponse;
const ChatConnectedData({
required this.messages,
this.isOtherTyping = false,
this.remainingSeconds,
this.sessionExpired = false,
this.sessionPaused = false,
this.sessionClosing = false,
this.extensionResponse,
});
ChatConnectedData copyWith({
List<ChatMessage>? messages,
bool? isOtherTyping,
int? remainingSeconds,
bool? sessionExpired,
bool? sessionPaused,
bool? sessionClosing,
Map<String, dynamic>? extensionResponse,
}) {
return ChatConnectedData(
messages: messages ?? this.messages,
isOtherTyping: isOtherTyping ?? this.isOtherTyping,
remainingSeconds: remainingSeconds ?? this.remainingSeconds,
sessionExpired: sessionExpired ?? this.sessionExpired,
sessionPaused: sessionPaused ?? this.sessionPaused,
sessionClosing: sessionClosing ?? this.sessionClosing,
extensionResponse: extensionResponse ?? this.extensionResponse,
);
}
}
class ChatErrorData extends ChatData {
final String message;
const ChatErrorData(this.message);
}
// Message model
class ChatMessage {
final String id;
final String senderType;
final String content;
final String type;
final String status;
final DateTime createdAt;
const ChatMessage({
required this.id,
required this.senderType,
required this.content,
this.type = MessageType.text,
this.status = MessageStatus.sent,
required this.createdAt,
});
ChatMessage copyWith({String? status}) {
return ChatMessage(
id: id,
senderType: senderType,
content: content,
type: type,
status: status ?? this.status,
createdAt: createdAt,
);
}
}
@Riverpod(keepAlive: true)
class Chat extends _$Chat {
WebSocketChannel? _channel;
StreamSubscription? _wsSubscription;
Timer? _typingTimer;
ApiClient get _apiClient => ref.read(apiClientProvider);
@override
ChatData build() => const ChatInitialData();
Future<void> connect(String sessionId) async {
state = const ChatConnectingData();
try {
final sessionInfo = await _apiClient.get('/api/shared/chat/$sessionId/info');
final sessionData = sessionInfo['data'] as Map<String, dynamic>?;
final sessionStatus = sessionData?['status'] as String?;
if (sessionStatus == SessionStatus.completed ||
sessionStatus == SessionStatus.cancelled ||
sessionStatus == SessionStatus.expired) {
state = const ChatErrorData('Sesi sudah berakhir.');
return;
}
final isClosing = sessionStatus == SessionStatus.closing;
final response = await _apiClient.get('/api/shared/chat/$sessionId/messages');
final messagesData = response['data'] as List<dynamic>;
final messages = messagesData.map((m) => ChatMessage(
id: m['id'] as String,
senderType: m['sender_type'] as String,
content: m['content'] as String,
type: m['type'] as String? ?? MessageType.text,
status: m['status'] as String? ?? MessageStatus.sent,
createdAt: DateTime.parse(m['created_at'] as String),
)).toList();
final user = FirebaseAuth.instance.currentUser;
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>;
_onMessageReceived(data);
},
onError: (_) {},
onDone: () {},
);
_channel!.sink.add(jsonEncode({
'type': WsMessage.auth,
'token': token,
'session_id': sessionId,
}));
state = ChatConnectedData(
messages: messages,
sessionClosing: isClosing,
);
} catch (e) {
state = const ChatErrorData('Gagal terhubung ke chat.');
}
}
void disconnect() {
_cleanup();
state = const ChatInitialData();
}
void sendMessage(String content) {
if (state is! ChatConnectedData || _channel == null) return;
final current = state as ChatConnectedData;
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
final msg = ChatMessage(
id: tempId,
senderType: UserType.customer,
content: content,
status: 'sending',
createdAt: DateTime.now(),
);
state = current.copyWith(messages: [...current.messages, msg]);
_channel!.sink.add(jsonEncode({
'type': WsMessage.message,
'content': content,
'_temp_id': tempId,
}));
}
void sendTyping() {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({'type': WsMessage.typing}));
}
void markDelivered(List<String> messageIds) {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({
'type': WsMessage.delivered,
'message_ids': messageIds,
}));
}
void markRead(List<String> messageIds) {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({
'type': WsMessage.read,
'message_ids': messageIds,
}));
}
void _onMessageReceived(Map<String, dynamic> data) {
if (state is! ChatConnectedData) return;
final current = state as ChatConnectedData;
final type = data['type'] as String?;
switch (type) {
case WsMessage.authOk:
break;
case WsMessage.message:
final msg = ChatMessage(
id: data['message_id'] as String,
senderType: data['sender_type'] as String,
content: data['content'] as String,
type: data['message_type'] as String? ?? MessageType.text,
status: MessageStatus.sent,
createdAt: DateTime.parse(data['created_at'] as String),
);
state = current.copyWith(messages: [...current.messages, msg]);
markDelivered([msg.id]);
break;
case WsMessage.messageAck:
final messageId = data['message_id'] as String;
final status = data['status'] as String;
final updatedMessages = current.messages.map((m) {
if (m.status == 'sending') {
return m.copyWith(status: status);
}
return m;
}).toList();
final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == UserType.customer);
if (idx >= 0) {
final old = updatedMessages[idx];
updatedMessages[idx] = ChatMessage(
id: messageId,
senderType: old.senderType,
content: old.content,
type: old.type,
status: status,
createdAt: old.createdAt,
);
}
state = current.copyWith(messages: updatedMessages);
break;
case WsMessage.messageStatus:
final messageIds = (data['message_ids'] as List<dynamic>).cast<String>();
final status = data['status'] as String;
final updatedMessages = current.messages.map((m) {
if (messageIds.contains(m.id)) {
return m.copyWith(status: status);
}
return m;
}).toList();
state = current.copyWith(messages: updatedMessages);
break;
case WsMessage.typing:
state = current.copyWith(isOtherTyping: true);
_typingTimer?.cancel();
_typingTimer = Timer(const Duration(seconds: 3), () {
if (state is ChatConnectedData) {
state = (state as ChatConnectedData).copyWith(isOtherTyping: false);
}
});
break;
case WsMessage.sessionTimer:
final remaining = data['remaining_seconds'] as int?;
state = current.copyWith(remainingSeconds: remaining);
break;
case WsMessage.sessionExpired:
state = current.copyWith(sessionExpired: true);
break;
case WsMessage.sessionPaused:
state = current.copyWith(sessionPaused: true);
break;
case WsMessage.sessionResumed:
state = current.copyWith(sessionPaused: false, sessionExpired: false, sessionClosing: false);
break;
case WsMessage.sessionClosing:
state = current.copyWith(sessionClosing: true);
break;
case WsMessage.extensionResponse:
final accepted = data['accepted'] as bool? ?? false;
state = current.copyWith(
extensionResponse: data,
sessionPaused: accepted ? false : current.sessionPaused,
sessionExpired: accepted ? false : current.sessionExpired,
);
break;
case WsMessage.sessionCompleted:
_cleanup();
break;
case WsMessage.error:
break;
}
}
void _cleanup() {
_wsSubscription?.cancel();
_wsSubscription = null;
_channel?.sink.close();
_channel = null;
_typingTimer?.cancel();
_typingTimer = null;
}
}

View File

@@ -0,0 +1,24 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'chat_notifier.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$chatHash() => r'c67d0e916a9474e5142d1f07649792cd448607e4';
/// See also [Chat].
@ProviderFor(Chat)
final chatProvider = NotifierProvider<Chat, ChatData>.internal(
Chat.new,
name: r'chatProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$chatHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$Chat = Notifier<ChatData>;
// 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

View File

@@ -0,0 +1,74 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../api/api_client_provider.dart';
part 'session_closure_notifier.g.dart';
// States
sealed class SessionClosureData {
const SessionClosureData();
}
class ClosureInitialData extends SessionClosureData {
const ClosureInitialData();
}
class ExtendingWaitingMitraData extends SessionClosureData {
const ExtendingWaitingMitraData();
}
class ClosureShowGoodbyeData extends SessionClosureData {
const ClosureShowGoodbyeData();
}
class ClosureSubmittingData extends SessionClosureData {
const ClosureSubmittingData();
}
class ClosureCompleteData extends SessionClosureData {
const ClosureCompleteData();
}
class ClosureErrorData extends SessionClosureData {
final String message;
const ClosureErrorData(this.message);
}
@Riverpod(keepAlive: true)
class SessionClosure extends _$SessionClosure {
@override
SessionClosureData build() => const ClosureInitialData();
Future<void> requestExtension(String sessionId, {required int durationMinutes, required int price}) async {
state = const ExtendingWaitingMitraData();
try {
await ref.read(apiClientProvider).post('/api/client/chat/session/$sessionId/extend', data: {
'duration_minutes': durationMinutes,
'price': price,
});
// Response will come via WebSocket (ChatBloc/ChatNotifier handles it)
} catch (e) {
state = const ClosureErrorData('Gagal meminta perpanjangan.');
}
}
void declineExtension() {
state = const ClosureShowGoodbyeData();
}
void reset() {
state = const ClosureInitialData();
}
Future<void> submitGoodbye(String sessionId, String message) async {
state = const ClosureSubmittingData();
try {
await ref.read(apiClientProvider).post('/api/shared/sessions/$sessionId/close-message', data: {
'message': message,
});
state = const ClosureCompleteData();
} catch (e) {
state = const ClosureErrorData('Gagal mengirim pesan penutup.');
}
}
}

View File

@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'session_closure_notifier.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$sessionClosureHash() => r'5799a386e1e9c925601567b1fb8c684be7c7e23c';
/// See also [SessionClosure].
@ProviderFor(SessionClosure)
final sessionClosureProvider =
NotifierProvider<SessionClosure, SessionClosureData>.internal(
SessionClosure.new,
name: r'sessionClosureProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$sessionClosureHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$SessionClosure = Notifier<SessionClosureData>;
// 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

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