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
|
||||
Reference in New Issue
Block a user