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:
332
client_app/lib/core/chat/chat_notifier.dart
Normal file
332
client_app/lib/core/chat/chat_notifier.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
24
client_app/lib/core/chat/chat_notifier.g.dart
Normal file
24
client_app/lib/core/chat/chat_notifier.g.dart
Normal 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
|
||||
74
client_app/lib/core/chat/session_closure_notifier.dart
Normal file
74
client_app/lib/core/chat/session_closure_notifier.dart
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
26
client_app/lib/core/chat/session_closure_notifier.g.dart
Normal file
26
client_app/lib/core/chat/session_closure_notifier.g.dart
Normal 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
|
||||
Reference in New Issue
Block a user