From bc66bbf50a0afc227cdbceaa360ca1ed8bbce1c3 Mon Sep 17 00:00:00 2001 From: ramadhan sjamsani Date: Thu, 9 Apr 2026 14:01:48 +0800 Subject: [PATCH] Phase 3.1: Complete client_app Riverpod migration (all blocs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- client_app/lib/core/chat/chat_notifier.dart | 332 ++++++++++++++++++ client_app/lib/core/chat/chat_notifier.g.dart | 24 ++ .../core/chat/session_closure_notifier.dart | 74 ++++ .../core/chat/session_closure_notifier.g.dart | 26 ++ .../lib/core/pairing/pairing_notifier.dart | 183 ++++++++++ .../lib/core/pairing/pairing_notifier.g.dart | 24 ++ .../chat/screens/bestie_found_screen.dart | 65 ++-- .../features/chat/screens/chat_screen.dart | 207 +++++------ .../chat/screens/searching_screen.dart | 85 +++-- .../chat/widgets/pricing_bottom_sheet.dart | 39 +- client_app/lib/features/home/home_screen.dart | 30 +- client_app/lib/main.dart | 22 +- 12 files changed, 860 insertions(+), 251 deletions(-) create mode 100644 client_app/lib/core/chat/chat_notifier.dart create mode 100644 client_app/lib/core/chat/chat_notifier.g.dart create mode 100644 client_app/lib/core/chat/session_closure_notifier.dart create mode 100644 client_app/lib/core/chat/session_closure_notifier.g.dart create mode 100644 client_app/lib/core/pairing/pairing_notifier.dart create mode 100644 client_app/lib/core/pairing/pairing_notifier.g.dart diff --git a/client_app/lib/core/chat/chat_notifier.dart b/client_app/lib/core/chat/chat_notifier.dart new file mode 100644 index 0000000..6db2fdc --- /dev/null +++ b/client_app/lib/core/chat/chat_notifier.dart @@ -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 messages; + final bool isOtherTyping; + final int? remainingSeconds; + final bool sessionExpired; + final bool sessionPaused; + final bool sessionClosing; + final Map? 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? messages, + bool? isOtherTyping, + int? remainingSeconds, + bool? sessionExpired, + bool? sessionPaused, + bool? sessionClosing, + Map? 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 connect(String sessionId) async { + state = const ChatConnectingData(); + + try { + final sessionInfo = await _apiClient.get('/api/shared/chat/$sessionId/info'); + final sessionData = sessionInfo['data'] as Map?; + 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; + 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; + _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 messageIds) { + if (_channel == null) return; + _channel!.sink.add(jsonEncode({ + 'type': WsMessage.delivered, + 'message_ids': messageIds, + })); + } + + void markRead(List messageIds) { + if (_channel == null) return; + _channel!.sink.add(jsonEncode({ + 'type': WsMessage.read, + 'message_ids': messageIds, + })); + } + + void _onMessageReceived(Map 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).cast(); + 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; + } +} diff --git a/client_app/lib/core/chat/chat_notifier.g.dart b/client_app/lib/core/chat/chat_notifier.g.dart new file mode 100644 index 0000000..07bd91f --- /dev/null +++ b/client_app/lib/core/chat/chat_notifier.g.dart @@ -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.internal( + Chat.new, + name: r'chatProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$chatHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$Chat = Notifier; +// 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 diff --git a/client_app/lib/core/chat/session_closure_notifier.dart b/client_app/lib/core/chat/session_closure_notifier.dart new file mode 100644 index 0000000..ebc6317 --- /dev/null +++ b/client_app/lib/core/chat/session_closure_notifier.dart @@ -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 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 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.'); + } + } +} diff --git a/client_app/lib/core/chat/session_closure_notifier.g.dart b/client_app/lib/core/chat/session_closure_notifier.g.dart new file mode 100644 index 0000000..14878ef --- /dev/null +++ b/client_app/lib/core/chat/session_closure_notifier.g.dart @@ -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.internal( + SessionClosure.new, + name: r'sessionClosureProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$sessionClosureHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$SessionClosure = Notifier; +// 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 diff --git a/client_app/lib/core/pairing/pairing_notifier.dart b/client_app/lib/core/pairing/pairing_notifier.dart new file mode 100644 index 0000000..d43a217 --- /dev/null +++ b/client_app/lib/core/pairing/pairing_notifier.dart @@ -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 requestPairing() async { + await _doPairingRequest({}); + } + + Future requestPairingWithTier({int? durationMinutes, int? price, bool isFreeTrial = false}) async { + final body = {}; + if (isFreeTrial) { + body['is_free_trial'] = true; + } else { + body['duration_minutes'] = durationMinutes; + body['price'] = price; + } + await _doPairingRequest(body); + } + + Future _doPairingRequest(Map 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; + 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 _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; + if (data['type'] == WsMessage.authOk) return; + _onStatusUpdate(data); + }, + onError: (_) {}, + onDone: () {}, + ); + + _channel!.sink.add(jsonEncode({ + 'type': WsMessage.auth, + 'token': token, + })); + } + + Future _onStatusUpdate(Map 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 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(); + } +} diff --git a/client_app/lib/core/pairing/pairing_notifier.g.dart b/client_app/lib/core/pairing/pairing_notifier.g.dart new file mode 100644 index 0000000..7b83fa9 --- /dev/null +++ b/client_app/lib/core/pairing/pairing_notifier.g.dart @@ -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.internal( + Pairing.new, + name: r'pairingProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$pairingHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$Pairing = Notifier; +// 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 diff --git a/client_app/lib/features/chat/screens/bestie_found_screen.dart b/client_app/lib/features/chat/screens/bestie_found_screen.dart index da8571a..b641728 100644 --- a/client_app/lib/features/chat/screens/bestie_found_screen.dart +++ b/client_app/lib/features/chat/screens/bestie_found_screen.dart @@ -1,9 +1,9 @@ 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/pairing/pairing_bloc.dart'; +import '../../../core/pairing/pairing_notifier.dart'; -class BestieFoundScreen extends StatelessWidget { +class BestieFoundScreen extends ConsumerWidget { final String sessionId; final String mitraName; @@ -14,36 +14,35 @@ class BestieFoundScreen extends StatelessWidget { }); @override - Widget build(BuildContext context) { - return BlocListener( - listener: (context, state) { - if (state is PairingActive) { - context.go('/chat/session/${state.sessionId}', extra: state.mitraName); - } - }, - child: Scaffold( - body: Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.check_circle, size: 80, color: Colors.green), - const SizedBox(height: 24), - const Text( - 'Bestie ditemukan!', - style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Text( - 'Menghubungkan kamu ke $mitraName', - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 16, color: Colors.grey), - ), - const SizedBox(height: 24), - const CircularProgressIndicator(), - ], - ), + Widget build(BuildContext context, WidgetRef ref) { + ref.listen(pairingProvider, (prev, next) { + if (next is PairingActiveData) { + context.go('/chat/session/${next.sessionId}', extra: next.mitraName); + } + }); + + return Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.check_circle, size: 80, color: Colors.green), + const SizedBox(height: 24), + const Text( + 'Bestie ditemukan!', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text( + 'Menghubungkan kamu ke $mitraName', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 16, color: Colors.grey), + ), + const SizedBox(height: 24), + const CircularProgressIndicator(), + ], ), ), ), diff --git a/client_app/lib/features/chat/screens/chat_screen.dart b/client_app/lib/features/chat/screens/chat_screen.dart index 39723a6..ca24221 100644 --- a/client_app/lib/features/chat/screens/chat_screen.dart +++ b/client_app/lib/features/chat/screens/chat_screen.dart @@ -1,23 +1,23 @@ import 'dart:async'; 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/chat/chat_bloc.dart'; -import '../../../core/chat/session_closure_bloc.dart'; +import '../../../core/chat/chat_notifier.dart'; +import '../../../core/chat/session_closure_notifier.dart'; import '../../../core/constants.dart'; import '../widgets/pricing_bottom_sheet.dart'; -class ChatScreen extends StatefulWidget { +class ChatScreen extends ConsumerStatefulWidget { final String sessionId; final String mitraName; const ChatScreen({super.key, required this.sessionId, required this.mitraName}); @override - State createState() => _ChatScreenState(); + ConsumerState createState() => _ChatScreenState(); } -class _ChatScreenState extends State { +class _ChatScreenState extends ConsumerState { final _messageController = TextEditingController(); final _scrollController = ScrollController(); Timer? _typingThrottle; @@ -25,12 +25,12 @@ class _ChatScreenState extends State { @override void initState() { super.initState(); - context.read().add(ConnectChat(widget.sessionId)); + ref.read(chatProvider.notifier).connect(widget.sessionId); } @override void dispose() { - context.read().add(DisconnectChat()); + ref.read(chatProvider.notifier).disconnect(); _messageController.dispose(); _scrollController.dispose(); _typingThrottle?.cancel(); @@ -51,122 +51,100 @@ class _ChatScreenState extends State { void _onTextChanged(String text) { if (_typingThrottle?.isActive ?? false) return; - context.read().add(SendTyping()); + ref.read(chatProvider.notifier).sendTyping(); _typingThrottle = Timer(const Duration(seconds: 2), () {}); } void _sendMessage() { final text = _messageController.text.trim(); if (text.isEmpty) return; - context.read().add(SendMessage(text)); + ref.read(chatProvider.notifier).sendMessage(text); _messageController.clear(); _scrollToBottom(); } @override Widget build(BuildContext context) { - return MultiBlocListener( - listeners: [ - BlocListener( - listenWhen: (prev, curr) { - if (prev is ChatConnected && curr is ChatConnected) { - return prev.sessionExpired != curr.sessionExpired || - prev.sessionClosing != curr.sessionClosing || - prev.sessionPaused != curr.sessionPaused || - prev.messages.length != curr.messages.length; - } - return true; - }, - listener: (context, state) { - if (state is ChatConnected) { - // Only trigger goodbye if closing AND not expired (expired shows extend dialog first) - if (state.sessionClosing && !state.sessionExpired) { - final closureState = context.read().state; - if (closureState is ClosureInitial) { - context.read().add(DeclineExtension()); - } - } - // Extension accepted — reset closure bloc to go back to chat - if (!state.sessionPaused && !state.sessionExpired && !state.sessionClosing) { - final closureState = context.read().state; - if (closureState is! ClosureInitial) { - context.read().add(ResetClosure()); - } - } - _scrollToBottom(); - // Auto-mark received messages as read - final unread = state.messages - .where((m) => m.senderType == UserType.mitra && m.status != MessageStatus.read) - .map((m) => m.id) - .toList(); - if (unread.isNotEmpty) { - context.read().add(MarkMessagesRead(unread)); - } - } - }, - ), - BlocListener( - listener: (context, state) { - if (state is ClosureComplete) { - context.go('/home'); - } - }, - ), - ], - child: Scaffold( - appBar: AppBar( - title: Text(widget.mitraName), - automaticallyImplyLeading: false, - actions: [ - BlocBuilder( - builder: (context, state) { - if (state is ChatConnected && state.remainingSeconds != null) { - return Padding( - padding: const EdgeInsets.only(right: 16), - child: Center( - child: Text( - '${state.remainingSeconds}s', - style: TextStyle( - color: state.remainingSeconds! < 30 ? Colors.red : null, - fontWeight: FontWeight.bold, - ), - ), - ), - ); - } - return const SizedBox.shrink(); - }, + final chatState = ref.watch(chatProvider); + final closureState = ref.watch(sessionClosureProvider); + + // Listen for closure complete to navigate home + ref.listen(sessionClosureProvider, (prev, next) { + if (next is ClosureCompleteData) { + context.go('/home'); + } + }); + + // Listen for chat state changes to manage closure state + ref.listen(chatProvider, (prev, next) { + if (next is ChatConnectedData) { + if (next.sessionClosing && !next.sessionExpired) { + final closure = ref.read(sessionClosureProvider); + if (closure is ClosureInitialData) { + ref.read(sessionClosureProvider.notifier).declineExtension(); + } + } + if (!next.sessionPaused && !next.sessionExpired && !next.sessionClosing) { + final closure = ref.read(sessionClosureProvider); + if (closure is! ClosureInitialData) { + ref.read(sessionClosureProvider.notifier).reset(); + } + } + _scrollToBottom(); + final unread = next.messages + .where((m) => m.senderType == UserType.mitra && m.status != MessageStatus.read) + .map((m) => m.id) + .toList(); + if (unread.isNotEmpty) { + ref.read(chatProvider.notifier).markRead(unread); + } + } + }); + + return Scaffold( + appBar: AppBar( + title: Text(widget.mitraName), + automaticallyImplyLeading: false, + actions: [ + if (chatState is ChatConnectedData && chatState.remainingSeconds != null) + Padding( + padding: const EdgeInsets.only(right: 16), + child: Center( + child: Text( + '${chatState.remainingSeconds}s', + style: TextStyle( + color: chatState.remainingSeconds! < 30 ? Colors.red : null, + fontWeight: FontWeight.bold, + ), + ), + ), ), - ], - ), - body: BlocBuilder( - builder: (context, state) { - if (state is ChatConnecting) { - return const Center(child: CircularProgressIndicator()); - } - if (state is ChatError) { - return Center(child: Text(state.message)); - } - if (state is ChatConnected) { - return _buildChatBody(context, state); - } - return const SizedBox.shrink(); - }, - ), + ], ), + body: _buildBody(chatState, closureState), ); } - Widget _buildChatBody(BuildContext context, ChatConnected state) { - // Show goodbye input (takes priority — user already decided to close) - final closureState = context.watch().state; - if (closureState is ClosureShowGoodbye || closureState is ClosureSubmitting) { - return _buildGoodbyeView(context, closureState); + Widget _buildBody(ChatData chatState, SessionClosureData closureState) { + if (chatState is ChatConnectingData) { + return const Center(child: CircularProgressIndicator()); + } + if (chatState is ChatErrorData) { + return Center(child: Text(chatState.message)); + } + if (chatState is ChatConnectedData) { + return _buildChatBody(chatState, closureState); + } + return const SizedBox.shrink(); + } + + Widget _buildChatBody(ChatConnectedData state, SessionClosureData closureState) { + if (closureState is ClosureShowGoodbyeData || closureState is ClosureSubmittingData) { + return _buildGoodbyeView(closureState); } - // Show session expired dialog (extend or close?) if (state.sessionExpired) { - return _buildExpiredView(context); + return _buildExpiredView(); } if (state.sessionPaused) { @@ -195,7 +173,7 @@ class _ChatScreenState extends State { child: Text('Bestie sedang mengetik...', style: TextStyle(color: Colors.grey, fontSize: 12)), ), ), - _buildInputBar(context, state), + _buildInputBar(), ], ); } @@ -250,7 +228,7 @@ class _ChatScreenState extends State { } } - Widget _buildInputBar(BuildContext context, ChatConnected state) { + Widget _buildInputBar() { return SafeArea( child: Padding( padding: const EdgeInsets.all(8), @@ -280,7 +258,7 @@ class _ChatScreenState extends State { ); } - Widget _buildExpiredView(BuildContext context) { + Widget _buildExpiredView() { return Center( child: Padding( padding: const EdgeInsets.all(32), @@ -289,9 +267,8 @@ class _ChatScreenState extends State { duration: const Duration(seconds: 300), builder: (context, remaining, _) { if (remaining <= 0) { - // Auto-decline when countdown reaches 0 WidgetsBinding.instance.addPostFrameCallback((_) { - context.read().add(DeclineExtension()); + ref.read(sessionClosureProvider.notifier).declineExtension(); }); } final minutes = remaining ~/ 60; @@ -320,7 +297,7 @@ class _ChatScreenState extends State { ), const SizedBox(height: 12), TextButton( - onPressed: () => context.read().add(DeclineExtension()), + onPressed: () => ref.read(sessionClosureProvider.notifier).declineExtension(), child: const Text('Tidak, akhiri sesi'), ), ], @@ -331,7 +308,7 @@ class _ChatScreenState extends State { ); } - Widget _buildGoodbyeView(BuildContext context, SessionClosureState closureState) { + Widget _buildGoodbyeView(SessionClosureData closureState) { final controller = TextEditingController(); return SingleChildScrollView( padding: const EdgeInsets.all(32), @@ -354,17 +331,17 @@ class _ChatScreenState extends State { ), const SizedBox(height: 16), ElevatedButton( - onPressed: closureState is ClosureSubmitting + onPressed: closureState is ClosureSubmittingData ? null : () { final text = controller.text.trim(); if (text.isNotEmpty) { - context.read().add( - SubmitGoodbye(sessionId: widget.sessionId, message: text), + ref.read(sessionClosureProvider.notifier).submitGoodbye( + widget.sessionId, text, ); } }, - child: closureState is ClosureSubmitting + child: closureState is ClosureSubmittingData ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Kirim & Selesai'), ), diff --git a/client_app/lib/features/chat/screens/searching_screen.dart b/client_app/lib/features/chat/screens/searching_screen.dart index 05a20a5..99a95e8 100644 --- a/client_app/lib/features/chat/screens/searching_screen.dart +++ b/client_app/lib/features/chat/screens/searching_screen.dart @@ -1,52 +1,51 @@ 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/pairing/pairing_bloc.dart'; +import '../../../core/pairing/pairing_notifier.dart'; -class SearchingScreen extends StatelessWidget { +class SearchingScreen extends ConsumerWidget { const SearchingScreen({super.key}); @override - Widget build(BuildContext context) { - return BlocListener( - listener: (context, state) { - if (state is PairingBestieFound) { - context.go('/chat/found', extra: { - 'sessionId': state.sessionId, - 'mitraName': state.mitraName, - }); - } else if (state is PairingNoBestie) { - context.go('/chat/no-bestie'); - } else if (state is PairingCancelled) { - context.go('/home'); - } - }, - child: Scaffold( - body: Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 32), - const Text( - 'Mencari Bestie...', - style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - const Text( - 'Tunggu sebentar ya, kami sedang mencarikan Bestie untukmu', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 16, color: Colors.grey), - ), - const SizedBox(height: 48), - OutlinedButton( - onPressed: () => context.read().add(CancelPairing()), - child: const Text('Batalkan'), - ), - ], - ), + Widget build(BuildContext context, WidgetRef ref) { + ref.listen(pairingProvider, (prev, next) { + if (next is PairingBestieFoundData) { + context.go('/chat/found', extra: { + 'sessionId': next.sessionId, + 'mitraName': next.mitraName, + }); + } else if (next is PairingNoBestieData) { + context.go('/chat/no-bestie'); + } else if (next is PairingCancelledData) { + context.go('/home'); + } + }); + + return Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 32), + const Text( + 'Mencari Bestie...', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + const Text( + 'Tunggu sebentar ya, kami sedang mencarikan Bestie untukmu', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 16, color: Colors.grey), + ), + const SizedBox(height: 48), + OutlinedButton( + onPressed: () => ref.read(pairingProvider.notifier).cancelPairing(), + child: const Text('Batalkan'), + ), + ], ), ), ), diff --git a/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart b/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart index 3674707..0710f2c 100644 --- a/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart +++ b/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart @@ -1,9 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_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'; +import '../../../core/chat/session_closure_notifier.dart'; +import '../../../core/pairing/pairing_notifier.dart'; class PricingBottomSheet extends ConsumerWidget { /// If set, the bottom sheet is in "extension" mode — selecting a tier extends the session. @@ -16,12 +15,7 @@ class PricingBottomSheet extends ConsumerWidget { return showModalBottomSheet( context: context, isScrollControlled: true, - builder: (_) => MultiBlocProvider( - providers: [ - BlocProvider.value(value: context.read()), - ], - child: const PricingBottomSheet(), - ), + builder: (_) => const PricingBottomSheet(), ); } @@ -30,12 +24,7 @@ class PricingBottomSheet extends ConsumerWidget { return showModalBottomSheet( context: context, isScrollControlled: true, - builder: (_) => MultiBlocProvider( - providers: [ - BlocProvider.value(value: context.read()), - ], - child: PricingBottomSheet(extensionSessionId: sessionId), - ), + builder: (_) => PricingBottomSheet(extensionSessionId: sessionId), ); } @@ -90,7 +79,7 @@ class PricingBottomSheet extends ConsumerWidget { trailing: const Icon(Icons.arrow_forward_ios, size: 16), onTap: () { Navigator.of(context).pop(); - _startPairing(context, isFreeTrial: true); + _startPairing(ref, isFreeTrial: true); }, ), ), @@ -107,14 +96,14 @@ class PricingBottomSheet extends ConsumerWidget { Navigator.of(context).pop(); if (isExtension) { _requestExtension( - context, + ref, sessionId: extensionSessionId!, durationMinutes: tier.durationMinutes, price: tier.price, ); } else { _startPairing( - context, + ref, durationMinutes: tier.durationMinutes, price: tier.price, ); @@ -130,19 +119,19 @@ class PricingBottomSheet extends ConsumerWidget { ); } - void _startPairing(BuildContext context, {bool isFreeTrial = false, int? durationMinutes, int? price}) { - context.read().add(RequestPairingWithTier( + void _startPairing(WidgetRef ref, {bool isFreeTrial = false, int? durationMinutes, int? price}) { + ref.read(pairingProvider.notifier).requestPairingWithTier( durationMinutes: durationMinutes, price: price, isFreeTrial: isFreeTrial, - )); + ); } - void _requestExtension(BuildContext context, {required String sessionId, required int durationMinutes, required int price}) { - context.read().add(RequestExtension( - sessionId: sessionId, + void _requestExtension(WidgetRef ref, {required String sessionId, required int durationMinutes, required int price}) { + ref.read(sessionClosureProvider.notifier).requestExtension( + sessionId, durationMinutes: durationMinutes, price: price, - )); + ); } } diff --git a/client_app/lib/features/home/home_screen.dart b/client_app/lib/features/home/home_screen.dart index 7b6982f..bdb0cc0 100644 --- a/client_app/lib/features/home/home_screen.dart +++ b/client_app/lib/features/home/home_screen.dart @@ -1,10 +1,9 @@ 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_notifier.dart'; import '../../core/api/api_client_provider.dart'; -import '../../core/pairing/pairing_bloc.dart'; +import '../../core/pairing/pairing_notifier.dart'; import '../chat/widgets/pricing_bottom_sheet.dart'; class HomeScreen extends ConsumerStatefulWidget { @@ -71,19 +70,19 @@ class _HomeScreenState extends ConsumerState with WidgetsBindingObse _ => '', }; - return BlocListener( - listener: (context, state) { - if (state is PairingSearching) { - context.go('/chat/searching'); - } else if (state is PairingNoBestie) { - context.go('/chat/no-bestie'); - } else if (state is PairingError) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(state.message)), - ); - } - }, - child: Scaffold( + ref.listen(pairingProvider, (prev, next) { + if (next is PairingSearchingData) { + context.go('/chat/searching'); + } else if (next is PairingNoBestieData) { + context.go('/chat/no-bestie'); + } else if (next is PairingErrorData) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(next.message)), + ); + } + }); + + return Scaffold( appBar: AppBar( title: const Text('Halo Bestie'), actions: [ @@ -130,7 +129,6 @@ class _HomeScreenState extends ConsumerState with WidgetsBindingObse ), ), ), - ), ); } } diff --git a/client_app/lib/main.dart b/client_app/lib/main.dart index 2768ddb..ff54a1b 100644 --- a/client_app/lib/main.dart +++ b/client_app/lib/main.dart @@ -1,13 +1,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: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'; import 'core/notifications/notification_service.dart'; import 'firebase_options.dart'; import 'router.dart'; @@ -49,7 +45,6 @@ class _AppState extends ConsumerState { @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) { @@ -58,23 +53,12 @@ class _AppState extends ConsumerState { }); 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(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, - ), + return MaterialApp.router( + title: 'Halo Bestie', + routerConfig: router, ); } }