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:
114
mitra_app/lib/core/auth/auth_notifier.dart
Normal file
114
mitra_app/lib/core/auth/auth_notifier.dart
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
mitra_app/lib/core/auth/auth_notifier.g.dart
Normal file
25
mitra_app/lib/core/auth/auth_notifier.g.dart
Normal 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
|
||||||
148
mitra_app/lib/core/chat/chat_request_notifier.dart
Normal file
148
mitra_app/lib/core/chat/chat_request_notifier.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
mitra_app/lib/core/chat/chat_request_notifier.g.dart
Normal file
25
mitra_app/lib/core/chat/chat_request_notifier.g.dart
Normal 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
|
||||||
79
mitra_app/lib/core/chat/extension_notifier.dart
Normal file
79
mitra_app/lib/core/chat/extension_notifier.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
26
mitra_app/lib/core/chat/extension_notifier.g.dart
Normal file
26
mitra_app/lib/core/chat/extension_notifier.g.dart
Normal 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
|
||||||
302
mitra_app/lib/core/chat/mitra_chat_notifier.dart
Normal file
302
mitra_app/lib/core/chat/mitra_chat_notifier.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
mitra_app/lib/core/chat/mitra_chat_notifier.g.dart
Normal file
24
mitra_app/lib/core/chat/mitra_chat_notifier.g.dart
Normal 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
|
||||||
97
mitra_app/lib/core/status/status_notifier.dart
Normal file
97
mitra_app/lib/core/status/status_notifier.dart
Normal 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 (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
mitra_app/lib/core/status/status_notifier.g.dart
Normal file
25
mitra_app/lib/core/status/status_notifier.g.dart
Normal 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
|
||||||
@@ -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'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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'),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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'),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user