From 35d470b851317b6a1c885f21e26fb59cad82c82c Mon Sep 17 00:00:00 2001 From: ramadhan sjamsani Date: Thu, 9 Apr 2026 14:08:45 +0800 Subject: [PATCH] Phase 3.1: Complete mitra_app Riverpod migration (all blocs, fix auth bug) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migrate AuthBloc → MitraAuthNotifier (fixes stuck-loading bug: now returns MitraAuthInitialData when currentUser is null) - Migrate StatusBloc → OnlineStatusNotifier (heartbeat timer + lifecycle) - Migrate ExtensionBloc → MitraExtensionNotifier (accept/reject + goodbye) - Migrate ChatRequestBloc → ChatRequestNotifier (WebSocket incoming requests) - Migrate MitraChatBloc → MitraChatNotifier (WebSocket chat + messages) - Update router to use Riverpod auth state for redirects - Remove all flutter_bloc usage from mitra_app screens and main.dart - MultiBlocProvider fully removed from mitra_app Co-Authored-By: Claude Opus 4.6 (1M context) --- mitra_app/lib/core/auth/auth_notifier.dart | 114 +++++++ mitra_app/lib/core/auth/auth_notifier.g.dart | 25 ++ .../lib/core/chat/chat_request_notifier.dart | 148 +++++++++ .../core/chat/chat_request_notifier.g.dart | 25 ++ .../lib/core/chat/extension_notifier.dart | 79 +++++ .../lib/core/chat/extension_notifier.g.dart | 26 ++ .../lib/core/chat/mitra_chat_notifier.dart | 302 ++++++++++++++++++ .../lib/core/chat/mitra_chat_notifier.g.dart | 24 ++ .../lib/core/status/status_notifier.dart | 97 ++++++ .../lib/core/status/status_notifier.g.dart | 25 ++ .../features/auth/screens/login_screen.dart | 103 +++--- .../lib/features/auth/screens/otp_screen.dart | 159 ++++----- .../chat/screens/mitra_chat_screen.dart | 252 +++++++-------- .../chat/widgets/incoming_request_sheet.dart | 12 +- mitra_app/lib/features/home/home_screen.dart | 229 +++++++------ mitra_app/lib/main.dart | 99 +++--- mitra_app/lib/router.dart | 40 ++- 17 files changed, 1298 insertions(+), 461 deletions(-) create mode 100644 mitra_app/lib/core/auth/auth_notifier.dart create mode 100644 mitra_app/lib/core/auth/auth_notifier.g.dart create mode 100644 mitra_app/lib/core/chat/chat_request_notifier.dart create mode 100644 mitra_app/lib/core/chat/chat_request_notifier.g.dart create mode 100644 mitra_app/lib/core/chat/extension_notifier.dart create mode 100644 mitra_app/lib/core/chat/extension_notifier.g.dart create mode 100644 mitra_app/lib/core/chat/mitra_chat_notifier.dart create mode 100644 mitra_app/lib/core/chat/mitra_chat_notifier.g.dart create mode 100644 mitra_app/lib/core/status/status_notifier.dart create mode 100644 mitra_app/lib/core/status/status_notifier.g.dart diff --git a/mitra_app/lib/core/auth/auth_notifier.dart b/mitra_app/lib/core/auth/auth_notifier.dart new file mode 100644 index 0000000..e7e0b3f --- /dev/null +++ b/mitra_app/lib/core/auth/auth_notifier.dart @@ -0,0 +1,114 @@ +import 'dart:async'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../api/api_client.dart'; +import '../api/api_client_provider.dart'; + +part 'auth_notifier.g.dart'; + +// States +sealed class MitraAuthData { + const MitraAuthData(); +} + +class MitraAuthInitialData extends MitraAuthData { + const MitraAuthInitialData(); +} + +class MitraAuthAuthenticatedData extends MitraAuthData { + final Map profile; + const MitraAuthAuthenticatedData(this.profile); +} + +class MitraAuthOtpSentData extends MitraAuthData { + final String verificationId; + const MitraAuthOtpSentData(this.verificationId); +} + +@Riverpod(keepAlive: true) +class MitraAuth extends _$MitraAuth { + FirebaseAuth get _auth => FirebaseAuth.instance; + ApiClient get _apiClient => ref.read(apiClientProvider); + ConfirmationResult? _webConfirmationResult; + + @override + FutureOr build() async { + if (_auth.currentUser != null) { + return await _verifyAndReturn(); + } + return const MitraAuthInitialData(); // FIX: was missing in BLoC version + } + + Future requestOtp(String phone) async { + state = const AsyncLoading(); + if (kIsWeb) { + try { + final confirmationResult = await _auth.signInWithPhoneNumber(phone); + _webConfirmationResult = confirmationResult; + state = const AsyncData(MitraAuthOtpSentData('web')); + } catch (e) { + state = AsyncError('Gagal mengirim OTP. Coba lagi.', StackTrace.current); + } + } else { + final completer = Completer(); + await _auth.verifyPhoneNumber( + phoneNumber: phone, + verificationCompleted: (_) { + if (!completer.isCompleted) completer.complete(); + }, + verificationFailed: (e) { + state = AsyncError('Gagal mengirim OTP. Coba lagi.', StackTrace.current); + if (!completer.isCompleted) completer.complete(); + }, + codeSent: (verificationId, _) { + state = AsyncData(MitraAuthOtpSentData(verificationId)); + if (!completer.isCompleted) completer.complete(); + }, + codeAutoRetrievalTimeout: (_) { + if (!completer.isCompleted) completer.complete(); + }, + ); + await completer.future; + } + } + + Future verifyOtp(String verificationId, String smsCode) async { + state = const AsyncLoading(); + try { + if (kIsWeb && _webConfirmationResult != null) { + await _webConfirmationResult!.confirm(smsCode); + } else { + final credential = PhoneAuthProvider.credential( + verificationId: verificationId, + smsCode: smsCode, + ); + await _auth.signInWithCredential(credential); + } + state = AsyncData(await _verifyAndReturn()); + } catch (e) { + state = AsyncError('OTP tidak valid. Coba lagi.', StackTrace.current); + } + } + + Future logout() async { + await _auth.signOut(); + state = const AsyncData(MitraAuthInitialData()); + } + + Future _verifyAndReturn() async { + try { + final response = await _apiClient.post('/api/mitra/auth/verify'); + return MitraAuthAuthenticatedData(response['data'] as Map); + } on Exception catch (e) { + await _auth.signOut(); + final msg = e.toString(); + if (msg.contains('ACCOUNT_NOT_FOUND')) { + throw Exception('Akun tidak ditemukan. Hubungi administrator.'); + } else if (msg.contains('ACCOUNT_INACTIVE')) { + throw Exception('Akun tidak aktif. Hubungi administrator.'); + } + throw Exception('Gagal masuk. Coba lagi.'); + } + } +} diff --git a/mitra_app/lib/core/auth/auth_notifier.g.dart b/mitra_app/lib/core/auth/auth_notifier.g.dart new file mode 100644 index 0000000..3ce200d --- /dev/null +++ b/mitra_app/lib/core/auth/auth_notifier.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'auth_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$mitraAuthHash() => r'65235a41cde3a37feef0b3004a0a48b508bf9ac9'; + +/// See also [MitraAuth]. +@ProviderFor(MitraAuth) +final mitraAuthProvider = + AsyncNotifierProvider.internal( + MitraAuth.new, + name: r'mitraAuthProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$mitraAuthHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$MitraAuth = AsyncNotifier; +// 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/mitra_app/lib/core/chat/chat_request_notifier.dart b/mitra_app/lib/core/chat/chat_request_notifier.dart new file mode 100644 index 0000000..abb237e --- /dev/null +++ b/mitra_app/lib/core/chat/chat_request_notifier.dart @@ -0,0 +1,148 @@ +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 'chat_request_notifier.g.dart'; + +// States +sealed class ChatRequestData { + const ChatRequestData(); +} + +class ChatRequestIdleData extends ChatRequestData { + const ChatRequestIdleData(); +} + +class ChatRequestListeningData extends ChatRequestData { + const ChatRequestListeningData(); +} + +class ChatRequestIncomingData extends ChatRequestData { + final String sessionId; + const ChatRequestIncomingData(this.sessionId); +} + +class ChatRequestAcceptingData extends ChatRequestData { + const ChatRequestAcceptingData(); +} + +class ChatRequestAcceptedData extends ChatRequestData { + final Map session; + const ChatRequestAcceptedData(this.session); +} + +class ChatRequestErrorData extends ChatRequestData { + final String message; + const ChatRequestErrorData(this.message); +} + +@Riverpod(keepAlive: true) +class ChatRequest extends _$ChatRequest { + WebSocketChannel? _channel; + StreamSubscription? _wsSubscription; + + ApiClient get _apiClient => ref.read(apiClientProvider); + + @override + ChatRequestData build() => const ChatRequestIdleData(); + + Future startListening() async { + _closeWebSocket(); + state = const ChatRequestListeningData(); + await _connectWebSocket(); + } + + void stopListening() { + _closeWebSocket(); + state = const ChatRequestIdleData(); + } + + Future _connectWebSocket() async { + try { + 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; + _onRequestReceived(data); + }, + onError: (_) => _onConnectionError(), + onDone: () => _onConnectionError(), + ); + + _channel!.sink.add(jsonEncode({ + 'type': WsMessage.auth, + 'token': token, + })); + } catch (_) { + _onConnectionError(); + } + } + + void _onConnectionError() { + _closeWebSocket(); + if (state is! ChatRequestIdleData) { + state = const ChatRequestListeningData(); + } + } + + void _onRequestReceived(Map data) { + final type = data['type'] as String?; + + if (type == WsMessage.chatRequest) { + state = ChatRequestIncomingData(data['session_id'] as String); + } else if (type == WsMessage.chatRequestClosed) { + if (state is ChatRequestIncomingData) { + state = const ChatRequestListeningData(); + } + } else if (type == 'session_rerouted') { + state = const ChatRequestListeningData(); + } else if (type == 'session_assigned') { + state = ChatRequestAcceptedData({'session_id': data['session_id']}); + } + } + + Future accept(String sessionId) async { + state = const ChatRequestAcceptingData(); + try { + final response = await _apiClient.post('/api/mitra/chat-requests/$sessionId/accept'); + state = ChatRequestAcceptedData(response['data'] as Map); + } on DioException catch (e) { + final code = e.response?.data?['error']?['code']; + if (code == 'REQUEST_UNAVAILABLE') { + state = const ChatRequestListeningData(); + } else { + state = const ChatRequestErrorData('Gagal menerima. Coba lagi.'); + } + } + } + + Future decline(String sessionId) async { + try { + await _apiClient.post('/api/mitra/chat-requests/$sessionId/decline'); + } catch (_) {} + state = const ChatRequestListeningData(); + } + + void _closeWebSocket() { + _wsSubscription?.cancel(); + _wsSubscription = null; + _channel?.sink.close(); + _channel = null; + } +} diff --git a/mitra_app/lib/core/chat/chat_request_notifier.g.dart b/mitra_app/lib/core/chat/chat_request_notifier.g.dart new file mode 100644 index 0000000..c331f6a --- /dev/null +++ b/mitra_app/lib/core/chat/chat_request_notifier.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chat_request_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$chatRequestHash() => r'b99836c687e861493c432ff5a5901a70f24ab1c7'; + +/// See also [ChatRequest]. +@ProviderFor(ChatRequest) +final chatRequestProvider = + NotifierProvider.internal( + ChatRequest.new, + name: r'chatRequestProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$chatRequestHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$ChatRequest = 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/mitra_app/lib/core/chat/extension_notifier.dart b/mitra_app/lib/core/chat/extension_notifier.dart new file mode 100644 index 0000000..6cf8934 --- /dev/null +++ b/mitra_app/lib/core/chat/extension_notifier.dart @@ -0,0 +1,79 @@ +import 'package:dio/dio.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../api/api_client_provider.dart'; + +part 'extension_notifier.g.dart'; + +// States +sealed class ExtensionData { + const ExtensionData(); +} + +class ExtensionIdleData extends ExtensionData { + const ExtensionIdleData(); +} + +class ExtensionRespondingData extends ExtensionData { + const ExtensionRespondingData(); +} + +class ExtensionShowGoodbyeData extends ExtensionData { + const ExtensionShowGoodbyeData(); +} + +class ExtensionSubmittingData extends ExtensionData { + const ExtensionSubmittingData(); +} + +class ExtensionCompleteData extends ExtensionData { + const ExtensionCompleteData(); +} + +class ExtensionErrorData extends ExtensionData { + final String message; + const ExtensionErrorData(this.message); +} + +@Riverpod(keepAlive: true) +class MitraExtension extends _$MitraExtension { + @override + ExtensionData build() => const ExtensionIdleData(); + + Future respond(String sessionId, {required String extensionId, required bool accepted}) async { + state = const ExtensionRespondingData(); + try { + await ref.read(apiClientProvider).post('/api/mitra/chat-requests/sessions/$sessionId/extend-response', data: { + 'extension_id': extensionId, + 'accepted': accepted, + }); + if (!accepted) { + state = const ExtensionShowGoodbyeData(); + } else { + state = const ExtensionIdleData(); + } + } on DioException catch (e) { + final code = e.response?.data?['error']?['code']; + if (code == 'EXTENSION_RESOLVED') { + state = const ExtensionShowGoodbyeData(); + } else { + state = const ExtensionErrorData('Gagal merespon perpanjangan.'); + } + } + } + + Future submitGoodbye(String sessionId, String message) async { + state = const ExtensionSubmittingData(); + try { + await ref.read(apiClientProvider).post('/api/shared/sessions/$sessionId/close-message', data: { + 'message': message, + }); + state = const ExtensionCompleteData(); + } catch (e) { + state = const ExtensionErrorData('Gagal mengirim pesan penutup.'); + } + } + + void reset() { + state = const ExtensionIdleData(); + } +} diff --git a/mitra_app/lib/core/chat/extension_notifier.g.dart b/mitra_app/lib/core/chat/extension_notifier.g.dart new file mode 100644 index 0000000..870fa01 --- /dev/null +++ b/mitra_app/lib/core/chat/extension_notifier.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'extension_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$mitraExtensionHash() => r'4eed73b51454238e2cd40a255c148f232f281913'; + +/// See also [MitraExtension]. +@ProviderFor(MitraExtension) +final mitraExtensionProvider = + NotifierProvider.internal( + MitraExtension.new, + name: r'mitraExtensionProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$mitraExtensionHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$MitraExtension = 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/mitra_app/lib/core/chat/mitra_chat_notifier.dart b/mitra_app/lib/core/chat/mitra_chat_notifier.dart new file mode 100644 index 0000000..3d4d8e4 --- /dev/null +++ b/mitra_app/lib/core/chat/mitra_chat_notifier.dart @@ -0,0 +1,302 @@ +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 'mitra_chat_notifier.g.dart'; + +// States +sealed class MitraChatData { + const MitraChatData(); +} + +class MitraChatInitialData extends MitraChatData { + const MitraChatInitialData(); +} + +class MitraChatConnectingData extends MitraChatData { + const MitraChatConnectingData(); +} + +class MitraChatConnectedData extends MitraChatData { + final List messages; + final bool isOtherTyping; + final int? remainingSeconds; + final bool sessionExpired; + final bool sessionClosing; + final Map? extensionRequest; + + const MitraChatConnectedData({ + required this.messages, + this.isOtherTyping = false, + this.remainingSeconds, + this.sessionExpired = false, + this.sessionClosing = false, + this.extensionRequest, + }); + + MitraChatConnectedData copyWith({ + List? messages, + bool? isOtherTyping, + int? remainingSeconds, + bool? sessionExpired, + bool? sessionClosing, + Map? extensionRequest, + bool clearExtensionRequest = false, + }) { + return MitraChatConnectedData( + messages: messages ?? this.messages, + isOtherTyping: isOtherTyping ?? this.isOtherTyping, + remainingSeconds: remainingSeconds ?? this.remainingSeconds, + sessionExpired: sessionExpired ?? this.sessionExpired, + sessionClosing: sessionClosing ?? this.sessionClosing, + extensionRequest: clearExtensionRequest ? null : (extensionRequest ?? this.extensionRequest), + ); + } +} + +class MitraChatErrorData extends MitraChatData { + final String message; + const MitraChatErrorData(this.message); +} + +// Message model +class MitraChatMessage { + final String id; + final String senderType; + final String content; + final String type; + final String status; + final DateTime createdAt; + + const MitraChatMessage({ + required this.id, + required this.senderType, + required this.content, + this.type = MessageType.text, + this.status = MessageStatus.sent, + required this.createdAt, + }); + + MitraChatMessage copyWith({String? status}) { + return MitraChatMessage( + id: id, + senderType: senderType, + content: content, + type: type, + status: status ?? this.status, + createdAt: createdAt, + ); + } +} + +@Riverpod(keepAlive: true) +class MitraChat extends _$MitraChat { + WebSocketChannel? _channel; + StreamSubscription? _wsSubscription; + Timer? _typingTimer; + + ApiClient get _apiClient => ref.read(apiClientProvider); + + @override + MitraChatData build() => const MitraChatInitialData(); + + Future connect(String sessionId) async { + state = const MitraChatConnectingData(); + 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 MitraChatErrorData('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) => MitraChatMessage( + 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 = MitraChatConnectedData(messages: messages, sessionClosing: isClosing); + } catch (e) { + state = const MitraChatErrorData('Gagal terhubung ke chat.'); + } + } + + void disconnect() { + _cleanup(); + state = const MitraChatInitialData(); + } + + void sendMessage(String content) { + if (state is! MitraChatConnectedData || _channel == null) return; + final current = state as MitraChatConnectedData; + + final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}'; + final msg = MitraChatMessage( + id: tempId, + senderType: UserType.mitra, + 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! MitraChatConnectedData) return; + final current = state as MitraChatConnectedData; + final type = data['type'] as String?; + + switch (type) { + case WsMessage.authOk: + break; + + case WsMessage.message: + final msg = MitraChatMessage( + 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.mitra); + if (idx >= 0) { + final old = updatedMessages[idx]; + updatedMessages[idx] = MitraChatMessage( + 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 MitraChatConnectedData) { + state = (state as MitraChatConnectedData).copyWith(isOtherTyping: false); + } + }); + break; + + case WsMessage.sessionTimer: + state = current.copyWith(remainingSeconds: data['remaining_seconds'] as int?); + break; + + case WsMessage.sessionExpired: + state = current.copyWith(sessionExpired: true); + break; + + case WsMessage.extensionRequest: + state = current.copyWith(extensionRequest: data); + break; + + case WsMessage.sessionResumed: + state = current.copyWith(sessionExpired: false, sessionClosing: false, clearExtensionRequest: true); + break; + + case WsMessage.sessionClosing: + state = current.copyWith(sessionClosing: true, clearExtensionRequest: true); + break; + + case WsMessage.sessionCompleted: + _cleanup(); + break; + } + } + + void _cleanup() { + _wsSubscription?.cancel(); + _wsSubscription = null; + _channel?.sink.close(); + _channel = null; + _typingTimer?.cancel(); + _typingTimer = null; + } +} diff --git a/mitra_app/lib/core/chat/mitra_chat_notifier.g.dart b/mitra_app/lib/core/chat/mitra_chat_notifier.g.dart new file mode 100644 index 0000000..e3e9e1c --- /dev/null +++ b/mitra_app/lib/core/chat/mitra_chat_notifier.g.dart @@ -0,0 +1,24 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'mitra_chat_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$mitraChatHash() => r'827aa874dbcf49c17f94c0507f5e0a4064bcede3'; + +/// See also [MitraChat]. +@ProviderFor(MitraChat) +final mitraChatProvider = NotifierProvider.internal( + MitraChat.new, + name: r'mitraChatProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$mitraChatHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$MitraChat = 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/mitra_app/lib/core/status/status_notifier.dart b/mitra_app/lib/core/status/status_notifier.dart new file mode 100644 index 0000000..4ab2489 --- /dev/null +++ b/mitra_app/lib/core/status/status_notifier.dart @@ -0,0 +1,97 @@ +import 'dart:async'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../api/api_client_provider.dart'; + +part 'status_notifier.g.dart'; + +// States +sealed class OnlineStatusData { + const OnlineStatusData(); +} + +class StatusInitialData extends OnlineStatusData { + const StatusInitialData(); +} + +class StatusLoadedData extends OnlineStatusData { + final bool isOnline; + const StatusLoadedData({required this.isOnline}); +} + +class StatusLoadingData extends OnlineStatusData { + const StatusLoadingData(); +} + +class StatusErrorData extends OnlineStatusData { + final String message; + const StatusErrorData(this.message); +} + +@Riverpod(keepAlive: true) +class OnlineStatus extends _$OnlineStatus { + Timer? _heartbeatTimer; + + @override + OnlineStatusData build() => const StatusInitialData(); + + Future load() async { + try { + final response = await ref.read(apiClientProvider).get('/api/mitra/status'); + final data = response['data'] as Map; + state = StatusLoadedData(isOnline: data['is_online'] as bool); + } catch (e) { + state = const StatusLoadedData(isOnline: false); + } + } + + Future toggleOnline() async { + state = const StatusLoadingData(); + try { + await ref.read(apiClientProvider).post('/api/mitra/status/online'); + _startHeartbeat(); + state = const StatusLoadedData(isOnline: true); + } catch (e) { + state = const StatusErrorData('Gagal mengubah status. Coba lagi.'); + } + } + + Future toggleOffline() async { + state = const StatusLoadingData(); + try { + await ref.read(apiClientProvider).post('/api/mitra/status/offline'); + _stopHeartbeat(); + state = const StatusLoadedData(isOnline: false); + } catch (e) { + state = const StatusErrorData('Gagal mengubah status. Coba lagi.'); + } + } + + void onAppPaused() { + _stopHeartbeat(); + } + + void onAppResumed() { + if (state is StatusLoadedData && (state as StatusLoadedData).isOnline) { + _startHeartbeat(); + } + load(); + } + + void _startHeartbeat() { + _stopHeartbeat(); + _heartbeatTimer = Timer.periodic(const Duration(seconds: 15), (_) { + _heartbeatTick(); + }); + } + + void _stopHeartbeat() { + _heartbeatTimer?.cancel(); + _heartbeatTimer = null; + } + + Future _heartbeatTick() async { + try { + await ref.read(apiClientProvider).post('/api/mitra/status/heartbeat'); + } catch (_) {} + } +} diff --git a/mitra_app/lib/core/status/status_notifier.g.dart b/mitra_app/lib/core/status/status_notifier.g.dart new file mode 100644 index 0000000..86b341f --- /dev/null +++ b/mitra_app/lib/core/status/status_notifier.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'status_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$onlineStatusHash() => r'6b42328eaba0f7934b0e3eaa54eb6b764f1c4e53'; + +/// See also [OnlineStatus]. +@ProviderFor(OnlineStatus) +final onlineStatusProvider = + NotifierProvider.internal( + OnlineStatus.new, + name: r'onlineStatusProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$onlineStatusHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$OnlineStatus = 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/mitra_app/lib/features/auth/screens/login_screen.dart b/mitra_app/lib/features/auth/screens/login_screen.dart index 7c362f3..a929e1a 100644 --- a/mitra_app/lib/features/auth/screens/login_screen.dart +++ b/mitra_app/lib/features/auth/screens/login_screen.dart @@ -1,16 +1,16 @@ 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_bloc.dart'; +import '../../../core/auth/auth_notifier.dart'; -class LoginScreen extends StatefulWidget { +class LoginScreen extends ConsumerStatefulWidget { const LoginScreen({super.key}); @override - State createState() => _LoginScreenState(); + ConsumerState createState() => _LoginScreenState(); } -class _LoginScreenState extends State { +class _LoginScreenState extends ConsumerState { final _phoneController = TextEditingController(); @override @@ -21,53 +21,54 @@ class _LoginScreenState extends State { @override Widget build(BuildContext context) { - return BlocListener( - listener: (context, state) { - if (state is AuthOtpSent) { - context.push('/otp', extra: _phoneController.text.trim()); - } - if (state is AuthError) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message))); - } - }, - child: Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text( - 'Halo Bestie Mitra', - style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold), - textAlign: TextAlign.center, + final authState = ref.watch(mitraAuthProvider); + final isLoading = authState is AsyncLoading; + + ref.listen(mitraAuthProvider, (prev, next) { + final data = next.valueOrNull; + if (data is MitraAuthOtpSentData) { + context.push('/otp', extra: _phoneController.text.trim()); + } + if (next is AsyncError) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString()))); + } + }); + + return Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Halo Bestie Mitra', + style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + TextField( + controller: _phoneController, + decoration: const InputDecoration( + labelText: 'Nomor HP', + hintText: '+628xxxxxxxxxx', + border: OutlineInputBorder(), ), - const SizedBox(height: 48), - TextField( - controller: _phoneController, - decoration: const InputDecoration( - labelText: 'Nomor HP', - hintText: '+628xxxxxxxxxx', - border: OutlineInputBorder(), - ), - keyboardType: TextInputType.phone, - ), - const SizedBox(height: 16), - BlocBuilder( - builder: (context, state) => ElevatedButton( - onPressed: state is AuthLoading ? null : () { - final phone = _phoneController.text.trim(); - if (phone.isEmpty) return; - context.read().add(PhoneOtpRequested(phone)); - }, - child: state is AuthLoading - ? const CircularProgressIndicator() - : const Text('Kirim OTP'), - ), - ), - ], - ), + keyboardType: TextInputType.phone, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: isLoading ? null : () { + final phone = _phoneController.text.trim(); + if (phone.isEmpty) return; + ref.read(mitraAuthProvider.notifier).requestOtp(phone); + }, + child: isLoading + ? const CircularProgressIndicator() + : const Text('Kirim OTP'), + ), + ], ), ), ), diff --git a/mitra_app/lib/features/auth/screens/otp_screen.dart b/mitra_app/lib/features/auth/screens/otp_screen.dart index 74430dc..326306c 100644 --- a/mitra_app/lib/features/auth/screens/otp_screen.dart +++ b/mitra_app/lib/features/auth/screens/otp_screen.dart @@ -1,20 +1,30 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../core/auth/auth_bloc.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/auth/auth_notifier.dart'; -class OtpScreen extends StatefulWidget { +class OtpScreen extends ConsumerStatefulWidget { final String phone; const OtpScreen({super.key, required this.phone}); @override - State createState() => _OtpScreenState(); + ConsumerState createState() => _OtpScreenState(); } -class _OtpScreenState extends State { +class _OtpScreenState extends ConsumerState { final List _controllers = List.generate(6, (_) => TextEditingController()); final List _focusNodes = List.generate(6, (_) => FocusNode()); + String? _verificationId; + + @override + void initState() { + super.initState(); + final data = ref.read(mitraAuthProvider).valueOrNull; + if (data is MitraAuthOtpSentData) { + _verificationId = data.verificationId; + } + } @override void dispose() { @@ -50,82 +60,83 @@ class _OtpScreenState extends State { void _submit() { final otp = _otp; - if (otp.length != 6) return; - final state = context.read().state; - final verificationId = state is AuthOtpSent ? state.verificationId : ''; - context.read().add(OtpVerified(verificationId, otp)); + if (otp.length != 6 || _verificationId == null) return; + ref.read(mitraAuthProvider.notifier).verifyOtp(_verificationId!, otp); } @override Widget build(BuildContext context) { - return BlocListener( - listener: (context, state) { - if (state is AuthError) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(state.message)), - ); - // Clear fields on error - for (final c in _controllers) { - c.clear(); - } - _focusNodes[0].requestFocus(); + final authState = ref.watch(mitraAuthProvider); + final isLoading = authState is AsyncLoading; + + // Update verification ID if state changes + final data = authState.valueOrNull; + if (data is MitraAuthOtpSentData) { + _verificationId = data.verificationId; + } + + ref.listen(mitraAuthProvider, (prev, next) { + if (next is AsyncError) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString()))); + for (final c in _controllers) { + c.clear(); } - }, - child: Scaffold( - appBar: AppBar(title: const Text('Masukkan OTP')), - body: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - 'Kode OTP telah dikirim ke ${widget.phone}', - textAlign: TextAlign.center, - ), - const SizedBox(height: 32), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: List.generate(6, (index) { - return SizedBox( - width: 48, - child: KeyboardListener( - focusNode: FocusNode(), - onKeyEvent: (event) => _onKeyDown(index, event), - child: TextField( - controller: _controllers[index], - focusNode: _focusNodes[index], - textAlign: TextAlign.center, - keyboardType: TextInputType.number, - maxLength: 1, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - ), - decoration: const InputDecoration( - counterText: '', - border: OutlineInputBorder(), - contentPadding: EdgeInsets.symmetric(vertical: 14), - ), - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - ], - onChanged: (value) => _onChanged(index, value), + _focusNodes[0].requestFocus(); + } + }); + + return Scaffold( + appBar: AppBar(title: const Text('Masukkan OTP')), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Kode OTP telah dikirim ke ${widget.phone}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate(6, (index) { + return SizedBox( + width: 48, + child: KeyboardListener( + focusNode: FocusNode(), + onKeyEvent: (event) => _onKeyDown(index, event), + child: TextField( + controller: _controllers[index], + focusNode: _focusNodes[index], + textAlign: TextAlign.center, + keyboardType: TextInputType.number, + maxLength: 1, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, ), + decoration: const InputDecoration( + counterText: '', + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric(vertical: 14), + ), + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + onChanged: (value) => _onChanged(index, value), ), - ); - }), - ), - const SizedBox(height: 32), - BlocBuilder( - builder: (context, state) => ElevatedButton( - onPressed: state is AuthLoading ? null : _submit, - child: state is AuthLoading - ? const CircularProgressIndicator() - : const Text('Verifikasi'), - ), - ), - ], - ), + ), + ); + }), + ), + const SizedBox(height: 32), + ElevatedButton( + onPressed: isLoading ? null : _submit, + child: isLoading + ? const CircularProgressIndicator() + : const Text('Verifikasi'), + ), + ], ), ), ); diff --git a/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart b/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart index 1e9d99e..6b7f915 100644 --- a/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart +++ b/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart @@ -1,22 +1,22 @@ 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/mitra_chat_bloc.dart'; -import '../../../core/chat/extension_bloc.dart'; +import '../../../core/chat/mitra_chat_notifier.dart'; +import '../../../core/chat/extension_notifier.dart'; import '../../../core/constants.dart'; -class MitraChatScreen extends StatefulWidget { +class MitraChatScreen extends ConsumerStatefulWidget { final String sessionId; final String customerName; const MitraChatScreen({super.key, required this.sessionId, required this.customerName}); @override - State createState() => _MitraChatScreenState(); + ConsumerState createState() => _MitraChatScreenState(); } -class _MitraChatScreenState extends State { +class _MitraChatScreenState extends ConsumerState { final _messageController = TextEditingController(); final _scrollController = ScrollController(); Timer? _typingThrottle; @@ -24,12 +24,12 @@ class _MitraChatScreenState extends State { @override void initState() { super.initState(); - context.read().add(ConnectChat(widget.sessionId)); + ref.read(mitraChatProvider.notifier).connect(widget.sessionId); } @override void dispose() { - context.read().add(DisconnectChat()); + ref.read(mitraChatProvider.notifier).disconnect(); _messageController.dispose(); _scrollController.dispose(); _typingThrottle?.cancel(); @@ -50,100 +50,89 @@ class _MitraChatScreenState extends State { void _onTextChanged(String text) { if (_typingThrottle?.isActive ?? false) return; - context.read().add(SendTyping()); + ref.read(mitraChatProvider.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(mitraChatProvider.notifier).sendMessage(text); _messageController.clear(); _scrollToBottom(); } @override Widget build(BuildContext context) { - return MultiBlocListener( - listeners: [ - BlocListener( - listener: (context, state) { - if (state is ChatConnected) { - _scrollToBottom(); - final unread = state.messages - .where((m) => m.senderType == UserType.customer && m.status != MessageStatus.read) - .map((m) => m.id) - .toList(); - if (unread.isNotEmpty) { - context.read().add(MarkMessagesRead(unread)); - } - if (state.sessionClosing) { - // Trigger goodbye view - } - } - }, - ), - BlocListener( - listener: (context, state) { - if (state is ExtensionComplete) { - context.go('/home'); - } - }, - ), - ], - child: Scaffold( - appBar: AppBar( - title: Text(widget.customerName), - 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(mitraChatProvider); + final extState = ref.watch(mitraExtensionProvider); + + // Listen for extension complete → navigate home + ref.listen(mitraExtensionProvider, (prev, next) { + if (next is ExtensionCompleteData) { + context.go('/home'); + } + }); + + // Listen for chat state changes + ref.listen(mitraChatProvider, (prev, next) { + if (next is MitraChatConnectedData) { + _scrollToBottom(); + final unread = next.messages + .where((m) => m.senderType == UserType.customer && m.status != MessageStatus.read) + .map((m) => m.id) + .toList(); + if (unread.isNotEmpty) { + ref.read(mitraChatProvider.notifier).markRead(unread); + } + } + }); + + return Scaffold( + appBar: AppBar( + title: Text(widget.customerName), + actions: [ + if (chatState is MitraChatConnectedData && 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, extState), ); } - Widget _buildChatBody(BuildContext context, ChatConnected state) { + Widget _buildBody(MitraChatData chatState, ExtensionData extState) { + if (chatState is MitraChatConnectingData) { + return const Center(child: CircularProgressIndicator()); + } + if (chatState is MitraChatErrorData) { + return Center(child: Text(chatState.message)); + } + if (chatState is MitraChatConnectedData) { + return _buildChatBody(chatState, extState); + } + return const SizedBox.shrink(); + } + + Widget _buildChatBody(MitraChatConnectedData state, ExtensionData extState) { // Extension request from customer if (state.extensionRequest != null) { - return _buildExtensionView(context, state.extensionRequest!); + return _buildExtensionView(state.extensionRequest!, extState); } // Goodbye view - final extState = context.watch().state; - if (state.sessionClosing || extState is ExtensionShowGoodbye || extState is ExtensionSubmitting) { - return _buildGoodbyeView(context, extState); + if (state.sessionClosing || extState is ExtensionShowGoodbyeData || extState is ExtensionSubmittingData) { + return _buildGoodbyeView(extState); } return Column( @@ -173,7 +162,7 @@ class _MitraChatScreenState extends State { ); } - Widget _buildMessageBubble(ChatMessage msg, bool isMe) { + Widget _buildMessageBubble(MitraChatMessage msg, bool isMe) { return Align( alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, child: Container( @@ -253,62 +242,57 @@ class _MitraChatScreenState extends State { ); } - Widget _buildExtensionView(BuildContext context, Map request) { + Widget _buildExtensionView(Map request, ExtensionData extState) { final duration = request['duration_minutes'] as int?; final extensionId = request['extension_id'] as String?; + final isResponding = extState is ExtensionRespondingData; - return BlocBuilder( - builder: (context, extState) { - final isResponding = extState is ExtensionResponding; - - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.timer, size: 64, color: Colors.orange), - const SizedBox(height: 16), - const Text('Permintaan Perpanjangan', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - Text('Customer ingin perpanjang $duration menit', textAlign: TextAlign.center), - const SizedBox(height: 24), - if (isResponding) - const CircularProgressIndicator() - else - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ElevatedButton( - style: ElevatedButton.styleFrom(backgroundColor: Colors.green), - onPressed: extensionId == null ? null : () => context.read().add(RespondToExtension( - sessionId: widget.sessionId, - extensionId: extensionId, - accepted: true, - )), - child: const Text('Terima', style: TextStyle(color: Colors.white)), - ), - const SizedBox(width: 16), - ElevatedButton( - style: ElevatedButton.styleFrom(backgroundColor: Colors.red), - onPressed: extensionId == null ? null : () => context.read().add(RespondToExtension( - sessionId: widget.sessionId, - extensionId: extensionId, - accepted: false, - )), - child: const Text('Tolak', style: TextStyle(color: Colors.white)), - ), - ], + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.timer, size: 64, color: Colors.orange), + const SizedBox(height: 16), + const Text('Permintaan Perpanjangan', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Text('Customer ingin perpanjang $duration menit', textAlign: TextAlign.center), + const SizedBox(height: 24), + if (isResponding) + const CircularProgressIndicator() + else + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: Colors.green), + onPressed: extensionId == null ? null : () => ref.read(mitraExtensionProvider.notifier).respond( + widget.sessionId, + extensionId: extensionId, + accepted: true, + ), + child: const Text('Terima', style: TextStyle(color: Colors.white)), ), - ], - ), - ), - ); - }, + const SizedBox(width: 16), + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + onPressed: extensionId == null ? null : () => ref.read(mitraExtensionProvider.notifier).respond( + widget.sessionId, + extensionId: extensionId, + accepted: false, + ), + child: const Text('Tolak', style: TextStyle(color: Colors.white)), + ), + ], + ), + ], + ), + ), ); } - Widget _buildGoodbyeView(BuildContext context, ExtensionState extState) { + Widget _buildGoodbyeView(ExtensionData extState) { final controller = TextEditingController(); return SingleChildScrollView( padding: const EdgeInsets.all(32), @@ -331,17 +315,17 @@ class _MitraChatScreenState extends State { ), const SizedBox(height: 16), ElevatedButton( - onPressed: extState is ExtensionSubmitting + onPressed: extState is ExtensionSubmittingData ? null : () { final text = controller.text.trim(); if (text.isNotEmpty) { - context.read().add( - SubmitGoodbye(sessionId: widget.sessionId, message: text), + ref.read(mitraExtensionProvider.notifier).submitGoodbye( + widget.sessionId, text, ); } }, - child: extState is ExtensionSubmitting + child: extState is ExtensionSubmittingData ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Kirim & Selesai'), ), diff --git a/mitra_app/lib/features/chat/widgets/incoming_request_sheet.dart b/mitra_app/lib/features/chat/widgets/incoming_request_sheet.dart index 4611ac5..a8b52e7 100644 --- a/mitra_app/lib/features/chat/widgets/incoming_request_sheet.dart +++ b/mitra_app/lib/features/chat/widgets/incoming_request_sheet.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../core/chat/chat_request_bloc.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/chat/chat_request_notifier.dart'; -class IncomingRequestSheet extends StatelessWidget { +class IncomingRequestSheet extends ConsumerWidget { final String sessionId; const IncomingRequestSheet({super.key, required this.sessionId}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return Container( padding: const EdgeInsets.all(24), child: Column( @@ -30,7 +30,7 @@ class IncomingRequestSheet extends StatelessWidget { Expanded( child: OutlinedButton( onPressed: () { - context.read().add(DeclineRequest(sessionId)); + ref.read(chatRequestProvider.notifier).decline(sessionId); Navigator.of(context).pop(); }, child: const Text('Tolak'), @@ -40,7 +40,7 @@ class IncomingRequestSheet extends StatelessWidget { Expanded( child: ElevatedButton( onPressed: () { - context.read().add(AcceptRequest(sessionId)); + ref.read(chatRequestProvider.notifier).accept(sessionId); Navigator.of(context).pop(); }, child: const Text('Terima'), diff --git a/mitra_app/lib/features/home/home_screen.dart b/mitra_app/lib/features/home/home_screen.dart index ccb3652..6b80bd3 100644 --- a/mitra_app/lib/features/home/home_screen.dart +++ b/mitra_app/lib/features/home/home_screen.dart @@ -1,19 +1,19 @@ 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_bloc.dart'; -import '../../core/status/status_bloc.dart'; -import '../../core/chat/chat_request_bloc.dart'; +import '../../core/auth/auth_notifier.dart'; +import '../../core/status/status_notifier.dart'; +import '../../core/chat/chat_request_notifier.dart'; import '../chat/widgets/incoming_request_sheet.dart'; -class HomeScreen extends StatefulWidget { +class HomeScreen extends ConsumerStatefulWidget { const HomeScreen({super.key}); @override - State createState() => _HomeScreenState(); + ConsumerState createState() => _HomeScreenState(); } -class _HomeScreenState extends State with WidgetsBindingObserver { +class _HomeScreenState extends ConsumerState with WidgetsBindingObserver { @override void initState() { super.initState(); @@ -29,9 +29,8 @@ class _HomeScreenState extends State with WidgetsBindingObserver { @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { - // Check if there's a pending request that was missed while backgrounded - final chatState = context.read().state; - if (chatState is ChatRequestIncoming) { + final chatState = ref.read(chatRequestProvider); + if (chatState is ChatRequestIncomingData) { _showIncomingRequest(chatState.sessionId); } } @@ -41,136 +40,128 @@ class _HomeScreenState extends State with WidgetsBindingObserver { showModalBottomSheet( context: context, isDismissible: false, - builder: (_) => BlocProvider.value( - value: context.read(), - child: IncomingRequestSheet(sessionId: sessionId), - ), + builder: (_) => IncomingRequestSheet(sessionId: sessionId), ); } @override Widget build(BuildContext context) { - return MultiBlocListener( - listeners: [ - BlocListener( - listener: (context, state) { - if (state is StatusLoaded && state.isOnline) { - context.read().add(StartListening()); - } else if (state is StatusLoaded && !state.isOnline) { - context.read().add(StopListening()); - } - }, - ), - BlocListener( - listener: (context, state) { - if (state is ChatRequestIncoming) { - _showIncomingRequest(state.sessionId); - } else if (state is ChatRequestAccepted) { - final session = state.session; - final sessionId = session['session_id'] as String? ?? session['id'] as String; - context.push('/chat/session/$sessionId', extra: { - 'customerName': session['customer_display_name'] as String? ?? 'Customer', - }); - } - }, - ), - ], - child: BlocBuilder( - builder: (context, authState) { - final displayName = authState is AuthAuthenticated - ? authState.profile['display_name'] as String - : ''; + final authState = ref.watch(mitraAuthProvider); + final authData = authState.valueOrNull; + final displayName = authData is MitraAuthAuthenticatedData + ? authData.profile['display_name'] as String + : ''; - return Scaffold( - appBar: AppBar( - title: const Text('Halo Bestie Mitra'), - actions: [ - IconButton( - icon: const Icon(Icons.logout), - onPressed: () => context.read().add(LogoutRequested()), - ), - ], - ), - body: Padding( - padding: const EdgeInsets.all(24), - child: Column( - children: [ - Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)), - const SizedBox(height: 32), - _StatusToggle(), - const SizedBox(height: 16), - _ActiveSessionsButton(), - ], - ), - ), - ); - }, + // Listen for status changes to start/stop chat request listening + ref.listen(onlineStatusProvider, (prev, next) { + if (next is StatusLoadedData && next.isOnline) { + ref.read(chatRequestProvider.notifier).startListening(); + } else if (next is StatusLoadedData && !next.isOnline) { + ref.read(chatRequestProvider.notifier).stopListening(); + } + }); + + // Listen for incoming chat requests + ref.listen(chatRequestProvider, (prev, next) { + if (next is ChatRequestIncomingData) { + _showIncomingRequest(next.sessionId); + } else if (next is ChatRequestAcceptedData) { + final session = next.session; + final sessionId = session['session_id'] as String? ?? session['id'] as String; + context.push('/chat/session/$sessionId', extra: { + 'customerName': session['customer_display_name'] as String? ?? 'Customer', + }); + } + }); + + return Scaffold( + appBar: AppBar( + title: const Text('Halo Bestie Mitra'), + actions: [ + IconButton( + icon: const Icon(Icons.logout), + onPressed: () => ref.read(mitraAuthProvider.notifier).logout(), + ), + ], + ), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)), + const SizedBox(height: 32), + const _StatusToggle(), + const SizedBox(height: 16), + const _ActiveSessionsButton(), + ], + ), ), ); } } -class _StatusToggle extends StatelessWidget { - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - final isOnline = state is StatusLoaded && state.isOnline; - final isLoading = state is StatusLoading; +class _StatusToggle extends ConsumerWidget { + const _StatusToggle(); - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + @override + Widget build(BuildContext context, WidgetRef ref) { + final statusState = ref.watch(onlineStatusProvider); + final isOnline = statusState is StatusLoadedData && statusState.isOnline; + final isLoading = statusState is StatusLoadingData; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - isOnline ? 'Online' : 'Offline', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: isOnline ? Colors.green : Colors.grey, - ), - ), - Text( - isOnline - ? 'Kamu siap menerima chat' - : 'Aktifkan untuk menerima chat', - style: const TextStyle(fontSize: 14, color: Colors.grey), - ), - ], + Text( + isOnline ? 'Online' : 'Offline', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: isOnline ? Colors.green : Colors.grey, + ), + ), + Text( + isOnline + ? 'Kamu siap menerima chat' + : 'Aktifkan untuk menerima chat', + style: const TextStyle(fontSize: 14, color: Colors.grey), ), - isLoading - ? const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Switch( - value: isOnline, - activeColor: Colors.green, - onChanged: (_) { - final bloc = context.read(); - if (isOnline) { - bloc.add(ToggleOffline()); - } else { - bloc.add(ToggleOnline()); - } - }, - ), ], ), - ), - ); - }, + isLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Switch( + value: isOnline, + activeColor: Colors.green, + onChanged: (_) { + final notifier = ref.read(onlineStatusProvider.notifier); + if (isOnline) { + notifier.toggleOffline(); + } else { + notifier.toggleOnline(); + } + }, + ), + ], + ), + ), ); } } class _ActiveSessionsButton extends StatelessWidget { + const _ActiveSessionsButton(); + @override Widget build(BuildContext context) { return Column( diff --git a/mitra_app/lib/main.dart b/mitra_app/lib/main.dart index 1c660f0..fca2292 100644 --- a/mitra_app/lib/main.dart +++ b/mitra_app/lib/main.dart @@ -1,15 +1,10 @@ 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 'package:go_router/go_router.dart'; -import 'core/api/api_client.dart'; -import 'core/auth/auth_bloc.dart'; -import 'core/status/status_bloc.dart'; -import 'core/chat/chat_request_bloc.dart'; -import 'core/chat/mitra_chat_bloc.dart'; -import 'core/chat/extension_bloc.dart'; +import 'core/api/api_client_provider.dart'; +import 'core/auth/auth_notifier.dart'; +import 'core/status/status_notifier.dart'; import 'core/notifications/notification_service.dart'; import 'firebase_options.dart'; import 'router.dart'; @@ -24,85 +19,69 @@ void main() async { runApp(const ProviderScope(child: App())); } -class App extends StatefulWidget { +class App extends ConsumerStatefulWidget { const App({super.key}); @override - State createState() => _AppState(); + ConsumerState createState() => _AppState(); } -class _AppState extends State with WidgetsBindingObserver { - late final ApiClient _apiClient; - late final AuthBloc _authBloc; - late final GoRouter _router; - late final StatusBloc _statusBloc; - late final ChatRequestBloc _chatRequestBloc; +class _AppState extends ConsumerState with WidgetsBindingObserver { + bool _fcmRegistered = false; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - _apiClient = ApiClient(); - _authBloc = AuthBloc(apiClient: _apiClient)..add(AppStarted()); - _router = buildRouter(_authBloc); - NotificationService.initialize(_router); - _statusBloc = StatusBloc(apiClient: _apiClient); - _chatRequestBloc = ChatRequestBloc(apiClient: _apiClient); - _registerFcmToken(); - } - - Future _registerFcmToken() { - return _authBloc.stream.where((s) => s is AuthAuthenticated).first.then((_) async { - try { - final token = await FirebaseMessaging.instance.getToken(); - if (token != null) { - await _apiClient.post('/api/shared/device-token', data: {'token': token}); - } - } catch (_) {} - }); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); - _authBloc.close(); - _router.dispose(); - _statusBloc.close(); - _chatRequestBloc.close(); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.paused || state == AppLifecycleState.detached) { - _statusBloc.add(AppPaused()); + ref.read(onlineStatusProvider.notifier).onAppPaused(); } else if (state == AppLifecycleState.resumed) { - _statusBloc.add(AppResumed()); + ref.read(onlineStatusProvider.notifier).onAppResumed(); } } + void _registerFcmToken() { + if (_fcmRegistered) return; + _fcmRegistered = true; + Future(() async { + try { + final token = await FirebaseMessaging.instance.getToken(); + if (token != null) { + await ref.read(apiClientProvider).post('/api/shared/device-token', data: {'token': token}); + } + } catch (_) { + _fcmRegistered = false; + } + }); + } + @override Widget build(BuildContext context) { - return MultiBlocProvider( - providers: [ - BlocProvider.value(value: _authBloc), - BlocProvider.value(value: _statusBloc), - BlocProvider.value(value: _chatRequestBloc), - BlocProvider(create: (_) => MitraChatBloc(apiClient: _apiClient)), - BlocProvider(create: (_) => ExtensionBloc(apiClient: _apiClient)), - RepositoryProvider.value(value: _apiClient), - ], - child: BlocListener( - listener: (context, state) { - if (state is AuthAuthenticated) { - _statusBloc.add(StatusLoadRequested()); - } - }, - child: MaterialApp.router( - title: 'Halo Bestie Mitra', - routerConfig: _router, - ), - ), + // Listen for auth changes to load status and register FCM + ref.listen(mitraAuthProvider, (prev, next) { + final data = next.valueOrNull; + if (data is MitraAuthAuthenticatedData) { + ref.read(onlineStatusProvider.notifier).load(); + _registerFcmToken(); + } + }); + + final router = ref.watch(routerProvider); + NotificationService.initialize(router); + + return MaterialApp.router( + title: 'Halo Bestie Mitra', + routerConfig: router, ); } } diff --git a/mitra_app/lib/router.dart b/mitra_app/lib/router.dart index edf9f42..a48fb4b 100644 --- a/mitra_app/lib/router.dart +++ b/mitra_app/lib/router.dart @@ -1,7 +1,7 @@ -import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'core/auth/auth_bloc.dart'; +import 'core/auth/auth_notifier.dart'; import 'features/splash/splash_screen.dart'; import 'features/auth/screens/login_screen.dart'; import 'features/auth/screens/otp_screen.dart'; @@ -11,34 +11,40 @@ import 'features/chat/screens/mitra_chat_screen.dart'; import 'features/chat/screens/chat_history_screen.dart'; import 'features/chat/screens/chat_transcript_screen.dart'; -class _BlocRefreshNotifier extends ChangeNotifier { - late final StreamSubscription _subscription; +class RouterNotifier extends ChangeNotifier { + final Ref _ref; - _BlocRefreshNotifier(AuthBloc bloc) { - _subscription = bloc.stream.listen((_) => notifyListeners()); - } - - @override - void dispose() { - _subscription.cancel(); - super.dispose(); + RouterNotifier(this._ref) { + _ref.listen(mitraAuthProvider, (_, __) => notifyListeners()); } } -GoRouter buildRouter(AuthBloc authBloc) { +final routerProvider = Provider((ref) => buildRouter(ref)); + +GoRouter buildRouter(Ref ref) { + final notifier = RouterNotifier(ref); + return GoRouter( initialLocation: '/splash', - refreshListenable: _BlocRefreshNotifier(authBloc), + refreshListenable: notifier, redirect: (context, state) { - final authState = authBloc.state; + final authState = ref.read(mitraAuthProvider); final isSplash = state.matchedLocation == '/splash'; final isAuthRoute = state.matchedLocation.startsWith('/login') || state.matchedLocation.startsWith('/otp'); // Show splash while loading - if (authState is AuthLoading) return isSplash ? null : '/splash'; + if (authState is AsyncLoading) return isSplash ? null : '/splash'; - if (authState is AuthAuthenticated) { + final data = authState.valueOrNull; + if (data == null) { + // Error state — show login + if (!isAuthRoute && !isSplash) return '/login'; + if (isSplash) return '/login'; + return null; + } + + if (data is MitraAuthAuthenticatedData) { return (isSplash || isAuthRoute) ? '/home' : null; } if (!isAuthRoute && !isSplash) return '/login';