Phase 3.1: Complete mitra_app Riverpod migration (all blocs, fix auth bug)

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 14:08:45 +08:00
parent bc66bbf50a
commit 35d470b851
17 changed files with 1298 additions and 461 deletions

View File

@@ -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<String, dynamic> 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<MitraAuthData> build() async {
if (_auth.currentUser != null) {
return await _verifyAndReturn();
}
return const MitraAuthInitialData(); // FIX: was missing in BLoC version
}
Future<void> 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<void>();
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<void> 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<void> logout() async {
await _auth.signOut();
state = const AsyncData(MitraAuthInitialData());
}
Future<MitraAuthData> _verifyAndReturn() async {
try {
final response = await _apiClient.post('/api/mitra/auth/verify');
return MitraAuthAuthenticatedData(response['data'] as Map<String, dynamic>);
} 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.');
}
}
}

View File

@@ -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<MitraAuth, MitraAuthData>.internal(
MitraAuth.new,
name: r'mitraAuthProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$mitraAuthHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$MitraAuth = AsyncNotifier<MitraAuthData>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -0,0 +1,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<String, dynamic> 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<void> startListening() async {
_closeWebSocket();
state = const ChatRequestListeningData();
await _connectWebSocket();
}
void stopListening() {
_closeWebSocket();
state = const ChatRequestIdleData();
}
Future<void> _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<String, dynamic>;
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<String, dynamic> 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<void> 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<String, dynamic>);
} 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<void> 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;
}
}

View File

@@ -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<ChatRequest, ChatRequestData>.internal(
ChatRequest.new,
name: r'chatRequestProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$chatRequestHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$ChatRequest = Notifier<ChatRequestData>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -0,0 +1,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<void> 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<void> 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();
}
}

View File

@@ -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<MitraExtension, ExtensionData>.internal(
MitraExtension.new,
name: r'mitraExtensionProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$mitraExtensionHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$MitraExtension = Notifier<ExtensionData>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -0,0 +1,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<MitraChatMessage> messages;
final bool isOtherTyping;
final int? remainingSeconds;
final bool sessionExpired;
final bool sessionClosing;
final Map<String, dynamic>? extensionRequest;
const MitraChatConnectedData({
required this.messages,
this.isOtherTyping = false,
this.remainingSeconds,
this.sessionExpired = false,
this.sessionClosing = false,
this.extensionRequest,
});
MitraChatConnectedData copyWith({
List<MitraChatMessage>? messages,
bool? isOtherTyping,
int? remainingSeconds,
bool? sessionExpired,
bool? sessionClosing,
Map<String, dynamic>? 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<void> connect(String sessionId) async {
state = const MitraChatConnectingData();
try {
final sessionInfo = await _apiClient.get('/api/shared/chat/$sessionId/info');
final sessionData = sessionInfo['data'] as Map<String, dynamic>?;
final sessionStatus = sessionData?['status'] as String?;
if (sessionStatus == SessionStatus.completed ||
sessionStatus == SessionStatus.cancelled ||
sessionStatus == SessionStatus.expired) {
state = const 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<dynamic>;
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<String, dynamic>;
_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<String> messageIds) {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({'type': WsMessage.delivered, 'message_ids': messageIds}));
}
void markRead(List<String> messageIds) {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({'type': WsMessage.read, 'message_ids': messageIds}));
}
void _onMessageReceived(Map<String, dynamic> data) {
if (state is! 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<dynamic>).cast<String>();
final status = data['status'] as String;
final updatedMessages = current.messages.map((m) {
if (messageIds.contains(m.id)) return m.copyWith(status: status);
return m;
}).toList();
state = current.copyWith(messages: updatedMessages);
break;
case WsMessage.typing:
state = current.copyWith(isOtherTyping: true);
_typingTimer?.cancel();
_typingTimer = Timer(const Duration(seconds: 3), () {
if (state is 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;
}
}

View File

@@ -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<MitraChat, MitraChatData>.internal(
MitraChat.new,
name: r'mitraChatProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$mitraChatHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$MitraChat = Notifier<MitraChatData>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -0,0 +1,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<void> load() async {
try {
final response = await ref.read(apiClientProvider).get('/api/mitra/status');
final data = response['data'] as Map<String, dynamic>;
state = StatusLoadedData(isOnline: data['is_online'] as bool);
} catch (e) {
state = const StatusLoadedData(isOnline: false);
}
}
Future<void> 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<void> 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<void> _heartbeatTick() async {
try {
await ref.read(apiClientProvider).post('/api/mitra/status/heartbeat');
} catch (_) {}
}
}

View File

@@ -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<OnlineStatus, OnlineStatusData>.internal(
OnlineStatus.new,
name: r'onlineStatusProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$onlineStatusHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$OnlineStatus = Notifier<OnlineStatusData>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -1,16 +1,16 @@
import 'package:flutter/material.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 '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}); const LoginScreen({super.key});
@override @override
State<LoginScreen> createState() => _LoginScreenState(); ConsumerState<LoginScreen> createState() => _LoginScreenState();
} }
class _LoginScreenState extends State<LoginScreen> { class _LoginScreenState extends ConsumerState<LoginScreen> {
final _phoneController = TextEditingController(); final _phoneController = TextEditingController();
@override @override
@@ -21,53 +21,54 @@ class _LoginScreenState extends State<LoginScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>( final authState = ref.watch(mitraAuthProvider);
listener: (context, state) { final isLoading = authState is AsyncLoading;
if (state is AuthOtpSent) {
context.push('/otp', extra: _phoneController.text.trim()); ref.listen(mitraAuthProvider, (prev, next) {
} final data = next.valueOrNull;
if (state is AuthError) { if (data is MitraAuthOtpSentData) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message))); context.push('/otp', extra: _phoneController.text.trim());
} }
}, if (next is AsyncError) {
child: Scaffold( ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
body: SafeArea( }
child: Padding( });
padding: const EdgeInsets.all(24),
child: Column( return Scaffold(
mainAxisAlignment: MainAxisAlignment.center, body: SafeArea(
crossAxisAlignment: CrossAxisAlignment.stretch, child: Padding(
children: [ padding: const EdgeInsets.all(24),
const Text( child: Column(
'Halo Bestie Mitra', mainAxisAlignment: MainAxisAlignment.center,
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold), crossAxisAlignment: CrossAxisAlignment.stretch,
textAlign: TextAlign.center, 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), keyboardType: TextInputType.phone,
TextField( ),
controller: _phoneController, const SizedBox(height: 16),
decoration: const InputDecoration( ElevatedButton(
labelText: 'Nomor HP', onPressed: isLoading ? null : () {
hintText: '+628xxxxxxxxxx', final phone = _phoneController.text.trim();
border: OutlineInputBorder(), if (phone.isEmpty) return;
), ref.read(mitraAuthProvider.notifier).requestOtp(phone);
keyboardType: TextInputType.phone, },
), child: isLoading
const SizedBox(height: 16), ? const CircularProgressIndicator()
BlocBuilder<AuthBloc, AuthState>( : const Text('Kirim OTP'),
builder: (context, state) => ElevatedButton( ),
onPressed: state is AuthLoading ? null : () { ],
final phone = _phoneController.text.trim();
if (phone.isEmpty) return;
context.read<AuthBloc>().add(PhoneOtpRequested(phone));
},
child: state is AuthLoading
? const CircularProgressIndicator()
: const Text('Kirim OTP'),
),
),
],
),
), ),
), ),
), ),

View File

@@ -1,20 +1,30 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/auth/auth_bloc.dart'; import '../../../core/auth/auth_notifier.dart';
class OtpScreen extends StatefulWidget { class OtpScreen extends ConsumerStatefulWidget {
final String phone; final String phone;
const OtpScreen({super.key, required this.phone}); const OtpScreen({super.key, required this.phone});
@override @override
State<OtpScreen> createState() => _OtpScreenState(); ConsumerState<OtpScreen> createState() => _OtpScreenState();
} }
class _OtpScreenState extends State<OtpScreen> { class _OtpScreenState extends ConsumerState<OtpScreen> {
final List<TextEditingController> _controllers = final List<TextEditingController> _controllers =
List.generate(6, (_) => TextEditingController()); List.generate(6, (_) => TextEditingController());
final List<FocusNode> _focusNodes = List.generate(6, (_) => FocusNode()); final List<FocusNode> _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 @override
void dispose() { void dispose() {
@@ -50,82 +60,83 @@ class _OtpScreenState extends State<OtpScreen> {
void _submit() { void _submit() {
final otp = _otp; final otp = _otp;
if (otp.length != 6) return; if (otp.length != 6 || _verificationId == null) return;
final state = context.read<AuthBloc>().state; ref.read(mitraAuthProvider.notifier).verifyOtp(_verificationId!, otp);
final verificationId = state is AuthOtpSent ? state.verificationId : '';
context.read<AuthBloc>().add(OtpVerified(verificationId, otp));
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>( final authState = ref.watch(mitraAuthProvider);
listener: (context, state) { final isLoading = authState is AsyncLoading;
if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar( // Update verification ID if state changes
SnackBar(content: Text(state.message)), final data = authState.valueOrNull;
); if (data is MitraAuthOtpSentData) {
// Clear fields on error _verificationId = data.verificationId;
for (final c in _controllers) { }
c.clear();
} ref.listen(mitraAuthProvider, (prev, next) {
_focusNodes[0].requestFocus(); if (next is AsyncError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
for (final c in _controllers) {
c.clear();
} }
}, _focusNodes[0].requestFocus();
child: Scaffold( }
appBar: AppBar(title: const Text('Masukkan OTP')), });
body: Padding(
padding: const EdgeInsets.all(24), return Scaffold(
child: Column( appBar: AppBar(title: const Text('Masukkan OTP')),
crossAxisAlignment: CrossAxisAlignment.stretch, body: Padding(
children: [ padding: const EdgeInsets.all(24),
Text( child: Column(
'Kode OTP telah dikirim ke ${widget.phone}', crossAxisAlignment: CrossAxisAlignment.stretch,
textAlign: TextAlign.center, children: [
), Text(
const SizedBox(height: 32), 'Kode OTP telah dikirim ke ${widget.phone}',
Row( textAlign: TextAlign.center,
mainAxisAlignment: MainAxisAlignment.spaceEvenly, ),
children: List.generate(6, (index) { const SizedBox(height: 32),
return SizedBox( Row(
width: 48, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
child: KeyboardListener( children: List.generate(6, (index) {
focusNode: FocusNode(), return SizedBox(
onKeyEvent: (event) => _onKeyDown(index, event), width: 48,
child: TextField( child: KeyboardListener(
controller: _controllers[index], focusNode: FocusNode(),
focusNode: _focusNodes[index], onKeyEvent: (event) => _onKeyDown(index, event),
textAlign: TextAlign.center, child: TextField(
keyboardType: TextInputType.number, controller: _controllers[index],
maxLength: 1, focusNode: _focusNodes[index],
style: const TextStyle( textAlign: TextAlign.center,
fontSize: 24, keyboardType: TextInputType.number,
fontWeight: FontWeight.bold, maxLength: 1,
), style: const TextStyle(
decoration: const InputDecoration( fontSize: 24,
counterText: '', fontWeight: FontWeight.bold,
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(vertical: 14),
),
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
onChanged: (value) => _onChanged(index, value),
), ),
decoration: const InputDecoration(
counterText: '',
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(vertical: 14),
),
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
onChanged: (value) => _onChanged(index, value),
), ),
); ),
}), );
), }),
const SizedBox(height: 32), ),
BlocBuilder<AuthBloc, AuthState>( const SizedBox(height: 32),
builder: (context, state) => ElevatedButton( ElevatedButton(
onPressed: state is AuthLoading ? null : _submit, onPressed: isLoading ? null : _submit,
child: state is AuthLoading child: isLoading
? const CircularProgressIndicator() ? const CircularProgressIndicator()
: const Text('Verifikasi'), : const Text('Verifikasi'),
), ),
), ],
],
),
), ),
), ),
); );

View File

@@ -1,22 +1,22 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.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 'package:go_router/go_router.dart';
import '../../../core/chat/mitra_chat_bloc.dart'; import '../../../core/chat/mitra_chat_notifier.dart';
import '../../../core/chat/extension_bloc.dart'; import '../../../core/chat/extension_notifier.dart';
import '../../../core/constants.dart'; import '../../../core/constants.dart';
class MitraChatScreen extends StatefulWidget { class MitraChatScreen extends ConsumerStatefulWidget {
final String sessionId; final String sessionId;
final String customerName; final String customerName;
const MitraChatScreen({super.key, required this.sessionId, required this.customerName}); const MitraChatScreen({super.key, required this.sessionId, required this.customerName});
@override @override
State<MitraChatScreen> createState() => _MitraChatScreenState(); ConsumerState<MitraChatScreen> createState() => _MitraChatScreenState();
} }
class _MitraChatScreenState extends State<MitraChatScreen> { class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
final _messageController = TextEditingController(); final _messageController = TextEditingController();
final _scrollController = ScrollController(); final _scrollController = ScrollController();
Timer? _typingThrottle; Timer? _typingThrottle;
@@ -24,12 +24,12 @@ class _MitraChatScreenState extends State<MitraChatScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
context.read<MitraChatBloc>().add(ConnectChat(widget.sessionId)); ref.read(mitraChatProvider.notifier).connect(widget.sessionId);
} }
@override @override
void dispose() { void dispose() {
context.read<MitraChatBloc>().add(DisconnectChat()); ref.read(mitraChatProvider.notifier).disconnect();
_messageController.dispose(); _messageController.dispose();
_scrollController.dispose(); _scrollController.dispose();
_typingThrottle?.cancel(); _typingThrottle?.cancel();
@@ -50,100 +50,89 @@ class _MitraChatScreenState extends State<MitraChatScreen> {
void _onTextChanged(String text) { void _onTextChanged(String text) {
if (_typingThrottle?.isActive ?? false) return; if (_typingThrottle?.isActive ?? false) return;
context.read<MitraChatBloc>().add(SendTyping()); ref.read(mitraChatProvider.notifier).sendTyping();
_typingThrottle = Timer(const Duration(seconds: 2), () {}); _typingThrottle = Timer(const Duration(seconds: 2), () {});
} }
void _sendMessage() { void _sendMessage() {
final text = _messageController.text.trim(); final text = _messageController.text.trim();
if (text.isEmpty) return; if (text.isEmpty) return;
context.read<MitraChatBloc>().add(SendMessage(text)); ref.read(mitraChatProvider.notifier).sendMessage(text);
_messageController.clear(); _messageController.clear();
_scrollToBottom(); _scrollToBottom();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiBlocListener( final chatState = ref.watch(mitraChatProvider);
listeners: [ final extState = ref.watch(mitraExtensionProvider);
BlocListener<MitraChatBloc, MitraChatState>(
listener: (context, state) { // Listen for extension complete → navigate home
if (state is ChatConnected) { ref.listen(mitraExtensionProvider, (prev, next) {
_scrollToBottom(); if (next is ExtensionCompleteData) {
final unread = state.messages context.go('/home');
.where((m) => m.senderType == UserType.customer && m.status != MessageStatus.read) }
.map((m) => m.id) });
.toList();
if (unread.isNotEmpty) { // Listen for chat state changes
context.read<MitraChatBloc>().add(MarkMessagesRead(unread)); ref.listen(mitraChatProvider, (prev, next) {
} if (next is MitraChatConnectedData) {
if (state.sessionClosing) { _scrollToBottom();
// Trigger goodbye view final unread = next.messages
} .where((m) => m.senderType == UserType.customer && m.status != MessageStatus.read)
} .map((m) => m.id)
}, .toList();
), if (unread.isNotEmpty) {
BlocListener<ExtensionBloc, ExtensionState>( ref.read(mitraChatProvider.notifier).markRead(unread);
listener: (context, state) { }
if (state is ExtensionComplete) { }
context.go('/home'); });
}
}, return Scaffold(
), appBar: AppBar(
], title: Text(widget.customerName),
child: Scaffold( actions: [
appBar: AppBar( if (chatState is MitraChatConnectedData && chatState.remainingSeconds != null)
title: Text(widget.customerName), Padding(
actions: [ padding: const EdgeInsets.only(right: 16),
BlocBuilder<MitraChatBloc, MitraChatState>( child: Center(
builder: (context, state) { child: Text(
if (state is ChatConnected && state.remainingSeconds != null) { '${chatState.remainingSeconds}s',
return Padding( style: TextStyle(
padding: const EdgeInsets.only(right: 16), color: chatState.remainingSeconds! < 30 ? Colors.red : null,
child: Center( fontWeight: FontWeight.bold,
child: Text( ),
'${state.remainingSeconds}s', ),
style: TextStyle( ),
color: state.remainingSeconds! < 30 ? Colors.red : null,
fontWeight: FontWeight.bold,
),
),
),
);
}
return const SizedBox.shrink();
},
), ),
], ],
),
body: BlocBuilder<MitraChatBloc, MitraChatState>(
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 // Extension request from customer
if (state.extensionRequest != null) { if (state.extensionRequest != null) {
return _buildExtensionView(context, state.extensionRequest!); return _buildExtensionView(state.extensionRequest!, extState);
} }
// Goodbye view // Goodbye view
final extState = context.watch<ExtensionBloc>().state; if (state.sessionClosing || extState is ExtensionShowGoodbyeData || extState is ExtensionSubmittingData) {
if (state.sessionClosing || extState is ExtensionShowGoodbye || extState is ExtensionSubmitting) { return _buildGoodbyeView(extState);
return _buildGoodbyeView(context, extState);
} }
return Column( return Column(
@@ -173,7 +162,7 @@ class _MitraChatScreenState extends State<MitraChatScreen> {
); );
} }
Widget _buildMessageBubble(ChatMessage msg, bool isMe) { Widget _buildMessageBubble(MitraChatMessage msg, bool isMe) {
return Align( return Align(
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: Container( child: Container(
@@ -253,62 +242,57 @@ class _MitraChatScreenState extends State<MitraChatScreen> {
); );
} }
Widget _buildExtensionView(BuildContext context, Map<String, dynamic> request) { Widget _buildExtensionView(Map<String, dynamic> request, ExtensionData extState) {
final duration = request['duration_minutes'] as int?; final duration = request['duration_minutes'] as int?;
final extensionId = request['extension_id'] as String?; final extensionId = request['extension_id'] as String?;
final isResponding = extState is ExtensionRespondingData;
return BlocBuilder<ExtensionBloc, ExtensionState>( return Center(
builder: (context, extState) { child: Padding(
final isResponding = extState is ExtensionResponding; padding: const EdgeInsets.all(32),
child: Column(
return Center( mainAxisAlignment: MainAxisAlignment.center,
child: Padding( children: [
padding: const EdgeInsets.all(32), const Icon(Icons.timer, size: 64, color: Colors.orange),
child: Column( const SizedBox(height: 16),
mainAxisAlignment: MainAxisAlignment.center, const Text('Permintaan Perpanjangan', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
children: [ const SizedBox(height: 8),
const Icon(Icons.timer, size: 64, color: Colors.orange), Text('Customer ingin perpanjang $duration menit', textAlign: TextAlign.center),
const SizedBox(height: 16), const SizedBox(height: 24),
const Text('Permintaan Perpanjangan', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), if (isResponding)
const SizedBox(height: 8), const CircularProgressIndicator()
Text('Customer ingin perpanjang $duration menit', textAlign: TextAlign.center), else
const SizedBox(height: 24), Row(
if (isResponding) mainAxisAlignment: MainAxisAlignment.center,
const CircularProgressIndicator() children: [
else ElevatedButton(
Row( style: ElevatedButton.styleFrom(backgroundColor: Colors.green),
mainAxisAlignment: MainAxisAlignment.center, onPressed: extensionId == null ? null : () => ref.read(mitraExtensionProvider.notifier).respond(
children: [ widget.sessionId,
ElevatedButton( extensionId: extensionId,
style: ElevatedButton.styleFrom(backgroundColor: Colors.green), accepted: true,
onPressed: extensionId == null ? null : () => context.read<ExtensionBloc>().add(RespondToExtension( ),
sessionId: widget.sessionId, child: const Text('Terima', style: TextStyle(color: Colors.white)),
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<ExtensionBloc>().add(RespondToExtension(
sessionId: widget.sessionId,
extensionId: extensionId,
accepted: false,
)),
child: const Text('Tolak', 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(); final controller = TextEditingController();
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.all(32), padding: const EdgeInsets.all(32),
@@ -331,17 +315,17 @@ class _MitraChatScreenState extends State<MitraChatScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
onPressed: extState is ExtensionSubmitting onPressed: extState is ExtensionSubmittingData
? null ? null
: () { : () {
final text = controller.text.trim(); final text = controller.text.trim();
if (text.isNotEmpty) { if (text.isNotEmpty) {
context.read<ExtensionBloc>().add( ref.read(mitraExtensionProvider.notifier).submitGoodbye(
SubmitGoodbye(sessionId: widget.sessionId, message: text), widget.sessionId, text,
); );
} }
}, },
child: extState is ExtensionSubmitting child: extState is ExtensionSubmittingData
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Kirim & Selesai'), : const Text('Kirim & Selesai'),
), ),

View File

@@ -1,13 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/chat/chat_request_bloc.dart'; import '../../../core/chat/chat_request_notifier.dart';
class IncomingRequestSheet extends StatelessWidget { class IncomingRequestSheet extends ConsumerWidget {
final String sessionId; final String sessionId;
const IncomingRequestSheet({super.key, required this.sessionId}); const IncomingRequestSheet({super.key, required this.sessionId});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
return Container( return Container(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: Column(
@@ -30,7 +30,7 @@ class IncomingRequestSheet extends StatelessWidget {
Expanded( Expanded(
child: OutlinedButton( child: OutlinedButton(
onPressed: () { onPressed: () {
context.read<ChatRequestBloc>().add(DeclineRequest(sessionId)); ref.read(chatRequestProvider.notifier).decline(sessionId);
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
child: const Text('Tolak'), child: const Text('Tolak'),
@@ -40,7 +40,7 @@ class IncomingRequestSheet extends StatelessWidget {
Expanded( Expanded(
child: ElevatedButton( child: ElevatedButton(
onPressed: () { onPressed: () {
context.read<ChatRequestBloc>().add(AcceptRequest(sessionId)); ref.read(chatRequestProvider.notifier).accept(sessionId);
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
child: const Text('Terima'), child: const Text('Terima'),

View File

@@ -1,19 +1,19 @@
import 'package:flutter/material.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 'package:go_router/go_router.dart';
import '../../core/auth/auth_bloc.dart'; import '../../core/auth/auth_notifier.dart';
import '../../core/status/status_bloc.dart'; import '../../core/status/status_notifier.dart';
import '../../core/chat/chat_request_bloc.dart'; import '../../core/chat/chat_request_notifier.dart';
import '../chat/widgets/incoming_request_sheet.dart'; import '../chat/widgets/incoming_request_sheet.dart';
class HomeScreen extends StatefulWidget { class HomeScreen extends ConsumerStatefulWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
@override @override
State<HomeScreen> createState() => _HomeScreenState(); ConsumerState<HomeScreen> createState() => _HomeScreenState();
} }
class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver { class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObserver {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -29,9 +29,8 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) { if (state == AppLifecycleState.resumed) {
// Check if there's a pending request that was missed while backgrounded final chatState = ref.read(chatRequestProvider);
final chatState = context.read<ChatRequestBloc>().state; if (chatState is ChatRequestIncomingData) {
if (chatState is ChatRequestIncoming) {
_showIncomingRequest(chatState.sessionId); _showIncomingRequest(chatState.sessionId);
} }
} }
@@ -41,136 +40,128 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isDismissible: false, isDismissible: false,
builder: (_) => BlocProvider.value( builder: (_) => IncomingRequestSheet(sessionId: sessionId),
value: context.read<ChatRequestBloc>(),
child: IncomingRequestSheet(sessionId: sessionId),
),
); );
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiBlocListener( final authState = ref.watch(mitraAuthProvider);
listeners: [ final authData = authState.valueOrNull;
BlocListener<StatusBloc, StatusState>( final displayName = authData is MitraAuthAuthenticatedData
listener: (context, state) { ? authData.profile['display_name'] as String
if (state is StatusLoaded && state.isOnline) { : '';
context.read<ChatRequestBloc>().add(StartListening());
} else if (state is StatusLoaded && !state.isOnline) {
context.read<ChatRequestBloc>().add(StopListening());
}
},
),
BlocListener<ChatRequestBloc, ChatRequestState>(
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<AuthBloc, AuthState>(
builder: (context, authState) {
final displayName = authState is AuthAuthenticated
? authState.profile['display_name'] as String
: '';
return Scaffold( // Listen for status changes to start/stop chat request listening
appBar: AppBar( ref.listen(onlineStatusProvider, (prev, next) {
title: const Text('Halo Bestie Mitra'), if (next is StatusLoadedData && next.isOnline) {
actions: [ ref.read(chatRequestProvider.notifier).startListening();
IconButton( } else if (next is StatusLoadedData && !next.isOnline) {
icon: const Icon(Icons.logout), ref.read(chatRequestProvider.notifier).stopListening();
onPressed: () => context.read<AuthBloc>().add(LogoutRequested()), }
), });
],
), // Listen for incoming chat requests
body: Padding( ref.listen(chatRequestProvider, (prev, next) {
padding: const EdgeInsets.all(24), if (next is ChatRequestIncomingData) {
child: Column( _showIncomingRequest(next.sessionId);
children: [ } else if (next is ChatRequestAcceptedData) {
Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)), final session = next.session;
const SizedBox(height: 32), final sessionId = session['session_id'] as String? ?? session['id'] as String;
_StatusToggle(), context.push('/chat/session/$sessionId', extra: {
const SizedBox(height: 16), 'customerName': session['customer_display_name'] as String? ?? 'Customer',
_ActiveSessionsButton(), });
], }
), });
),
); 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 { class _StatusToggle extends ConsumerWidget {
@override const _StatusToggle();
Widget build(BuildContext context) {
return BlocBuilder<StatusBloc, StatusState>(
builder: (context, state) {
final isOnline = state is StatusLoaded && state.isOnline;
final isLoading = state is StatusLoading;
return Card( @override
child: Padding( Widget build(BuildContext context, WidgetRef ref) {
padding: const EdgeInsets.all(16), final statusState = ref.watch(onlineStatusProvider);
child: Row( final isOnline = statusState is StatusLoadedData && statusState.isOnline;
mainAxisAlignment: MainAxisAlignment.spaceBetween, 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: [ children: [
Column( Text(
crossAxisAlignment: CrossAxisAlignment.start, isOnline ? 'Online' : 'Offline',
children: [ style: TextStyle(
Text( fontSize: 18,
isOnline ? 'Online' : 'Offline', fontWeight: FontWeight.bold,
style: TextStyle( color: isOnline ? Colors.green : Colors.grey,
fontSize: 18, ),
fontWeight: FontWeight.bold, ),
color: isOnline ? Colors.green : Colors.grey, Text(
), isOnline
), ? 'Kamu siap menerima chat'
Text( : 'Aktifkan untuk menerima chat',
isOnline style: const TextStyle(fontSize: 14, color: Colors.grey),
? '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<StatusBloc>();
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 { class _ActiveSessionsButton extends StatelessWidget {
const _ActiveSessionsButton();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(

View File

@@ -1,15 +1,10 @@
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'core/api/api_client_provider.dart';
import 'core/api/api_client.dart'; import 'core/auth/auth_notifier.dart';
import 'core/auth/auth_bloc.dart'; import 'core/status/status_notifier.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/notifications/notification_service.dart'; import 'core/notifications/notification_service.dart';
import 'firebase_options.dart'; import 'firebase_options.dart';
import 'router.dart'; import 'router.dart';
@@ -24,85 +19,69 @@ void main() async {
runApp(const ProviderScope(child: App())); runApp(const ProviderScope(child: App()));
} }
class App extends StatefulWidget { class App extends ConsumerStatefulWidget {
const App({super.key}); const App({super.key});
@override @override
State<App> createState() => _AppState(); ConsumerState<App> createState() => _AppState();
} }
class _AppState extends State<App> with WidgetsBindingObserver { class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
late final ApiClient _apiClient; bool _fcmRegistered = false;
late final AuthBloc _authBloc;
late final GoRouter _router;
late final StatusBloc _statusBloc;
late final ChatRequestBloc _chatRequestBloc;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); 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<void> _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 @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
_authBloc.close();
_router.dispose();
_statusBloc.close();
_chatRequestBloc.close();
super.dispose(); super.dispose();
} }
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused || state == AppLifecycleState.detached) { if (state == AppLifecycleState.paused || state == AppLifecycleState.detached) {
_statusBloc.add(AppPaused()); ref.read(onlineStatusProvider.notifier).onAppPaused();
} else if (state == AppLifecycleState.resumed) { } 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiBlocProvider( // Listen for auth changes to load status and register FCM
providers: [ ref.listen(mitraAuthProvider, (prev, next) {
BlocProvider.value(value: _authBloc), final data = next.valueOrNull;
BlocProvider.value(value: _statusBloc), if (data is MitraAuthAuthenticatedData) {
BlocProvider.value(value: _chatRequestBloc), ref.read(onlineStatusProvider.notifier).load();
BlocProvider(create: (_) => MitraChatBloc(apiClient: _apiClient)), _registerFcmToken();
BlocProvider(create: (_) => ExtensionBloc(apiClient: _apiClient)), }
RepositoryProvider.value(value: _apiClient), });
],
child: BlocListener<AuthBloc, AuthState>( final router = ref.watch(routerProvider);
listener: (context, state) { NotificationService.initialize(router);
if (state is AuthAuthenticated) {
_statusBloc.add(StatusLoadRequested()); return MaterialApp.router(
} title: 'Halo Bestie Mitra',
}, routerConfig: router,
child: MaterialApp.router(
title: 'Halo Bestie Mitra',
routerConfig: _router,
),
),
); );
} }
} }

View File

@@ -1,7 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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/splash/splash_screen.dart';
import 'features/auth/screens/login_screen.dart'; import 'features/auth/screens/login_screen.dart';
import 'features/auth/screens/otp_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_history_screen.dart';
import 'features/chat/screens/chat_transcript_screen.dart'; import 'features/chat/screens/chat_transcript_screen.dart';
class _BlocRefreshNotifier extends ChangeNotifier { class RouterNotifier extends ChangeNotifier {
late final StreamSubscription _subscription; final Ref _ref;
_BlocRefreshNotifier(AuthBloc bloc) { RouterNotifier(this._ref) {
_subscription = bloc.stream.listen((_) => notifyListeners()); _ref.listen(mitraAuthProvider, (_, __) => notifyListeners());
}
@override
void dispose() {
_subscription.cancel();
super.dispose();
} }
} }
GoRouter buildRouter(AuthBloc authBloc) { final routerProvider = Provider<GoRouter>((ref) => buildRouter(ref));
GoRouter buildRouter(Ref ref) {
final notifier = RouterNotifier(ref);
return GoRouter( return GoRouter(
initialLocation: '/splash', initialLocation: '/splash',
refreshListenable: _BlocRefreshNotifier(authBloc), refreshListenable: notifier,
redirect: (context, state) { redirect: (context, state) {
final authState = authBloc.state; final authState = ref.read(mitraAuthProvider);
final isSplash = state.matchedLocation == '/splash'; final isSplash = state.matchedLocation == '/splash';
final isAuthRoute = state.matchedLocation.startsWith('/login') || final isAuthRoute = state.matchedLocation.startsWith('/login') ||
state.matchedLocation.startsWith('/otp'); state.matchedLocation.startsWith('/otp');
// Show splash while loading // 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; return (isSplash || isAuthRoute) ? '/home' : null;
} }
if (!isAuthRoute && !isSplash) return '/login'; if (!isAuthRoute && !isSplash) return '/login';