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_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<LoginScreen> createState() => _LoginScreenState();
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _phoneController = TextEditingController();
@override
@@ -21,53 +21,54 @@ class _LoginScreenState extends State<LoginScreen> {
@override
Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>(
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<AuthBloc, AuthState>(
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'),
),
),
],
),
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'),
),
],
),
),
),

View File

@@ -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<OtpScreen> createState() => _OtpScreenState();
ConsumerState<OtpScreen> createState() => _OtpScreenState();
}
class _OtpScreenState extends State<OtpScreen> {
class _OtpScreenState extends ConsumerState<OtpScreen> {
final List<TextEditingController> _controllers =
List.generate(6, (_) => TextEditingController());
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
void dispose() {
@@ -50,82 +60,83 @@ class _OtpScreenState extends State<OtpScreen> {
void _submit() {
final otp = _otp;
if (otp.length != 6) return;
final state = context.read<AuthBloc>().state;
final verificationId = state is AuthOtpSent ? state.verificationId : '';
context.read<AuthBloc>().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<AuthBloc, AuthState>(
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<AuthBloc, AuthState>(
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'),
),
],
),
),
);

View File

@@ -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<MitraChatScreen> createState() => _MitraChatScreenState();
ConsumerState<MitraChatScreen> createState() => _MitraChatScreenState();
}
class _MitraChatScreenState extends State<MitraChatScreen> {
class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
final _messageController = TextEditingController();
final _scrollController = ScrollController();
Timer? _typingThrottle;
@@ -24,12 +24,12 @@ class _MitraChatScreenState extends State<MitraChatScreen> {
@override
void initState() {
super.initState();
context.read<MitraChatBloc>().add(ConnectChat(widget.sessionId));
ref.read(mitraChatProvider.notifier).connect(widget.sessionId);
}
@override
void dispose() {
context.read<MitraChatBloc>().add(DisconnectChat());
ref.read(mitraChatProvider.notifier).disconnect();
_messageController.dispose();
_scrollController.dispose();
_typingThrottle?.cancel();
@@ -50,100 +50,89 @@ class _MitraChatScreenState extends State<MitraChatScreen> {
void _onTextChanged(String text) {
if (_typingThrottle?.isActive ?? false) return;
context.read<MitraChatBloc>().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<MitraChatBloc>().add(SendMessage(text));
ref.read(mitraChatProvider.notifier).sendMessage(text);
_messageController.clear();
_scrollToBottom();
}
@override
Widget build(BuildContext context) {
return MultiBlocListener(
listeners: [
BlocListener<MitraChatBloc, MitraChatState>(
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<MitraChatBloc>().add(MarkMessagesRead(unread));
}
if (state.sessionClosing) {
// Trigger goodbye view
}
}
},
),
BlocListener<ExtensionBloc, ExtensionState>(
listener: (context, state) {
if (state is ExtensionComplete) {
context.go('/home');
}
},
),
],
child: Scaffold(
appBar: AppBar(
title: Text(widget.customerName),
actions: [
BlocBuilder<MitraChatBloc, MitraChatState>(
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<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
if (state.extensionRequest != null) {
return _buildExtensionView(context, state.extensionRequest!);
return _buildExtensionView(state.extensionRequest!, extState);
}
// Goodbye view
final extState = context.watch<ExtensionBloc>().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<MitraChatScreen> {
);
}
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<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 extensionId = request['extension_id'] as String?;
final isResponding = extState is ExtensionRespondingData;
return BlocBuilder<ExtensionBloc, ExtensionState>(
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<ExtensionBloc>().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<ExtensionBloc>().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<MitraChatScreen> {
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: extState is ExtensionSubmitting
onPressed: extState is ExtensionSubmittingData
? null
: () {
final text = controller.text.trim();
if (text.isNotEmpty) {
context.read<ExtensionBloc>().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'),
),

View File

@@ -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<ChatRequestBloc>().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<ChatRequestBloc>().add(AcceptRequest(sessionId));
ref.read(chatRequestProvider.notifier).accept(sessionId);
Navigator.of(context).pop();
},
child: const Text('Terima'),

View File

@@ -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<HomeScreen> createState() => _HomeScreenState();
ConsumerState<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
@@ -29,9 +29,8 @@ class _HomeScreenState extends State<HomeScreen> 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<ChatRequestBloc>().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<HomeScreen> with WidgetsBindingObserver {
showModalBottomSheet(
context: context,
isDismissible: false,
builder: (_) => BlocProvider.value(
value: context.read<ChatRequestBloc>(),
child: IncomingRequestSheet(sessionId: sessionId),
),
builder: (_) => IncomingRequestSheet(sessionId: sessionId),
);
}
@override
Widget build(BuildContext context) {
return MultiBlocListener(
listeners: [
BlocListener<StatusBloc, StatusState>(
listener: (context, state) {
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
: '';
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<AuthBloc>().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<StatusBloc, StatusState>(
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<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 {
const _ActiveSessionsButton();
@override
Widget build(BuildContext context) {
return Column(

View File

@@ -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<App> createState() => _AppState();
ConsumerState<App> createState() => _AppState();
}
class _AppState extends State<App> 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<App> 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<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
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<AuthBloc, AuthState>(
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,
);
}
}

View File

@@ -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<GoRouter>((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';