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

- Migrate AuthBloc → MitraAuthNotifier (fixes stuck-loading bug: now returns
  MitraAuthInitialData when currentUser is null)
- Migrate StatusBloc → OnlineStatusNotifier (heartbeat timer + lifecycle)
- Migrate ExtensionBloc → MitraExtensionNotifier (accept/reject + goodbye)
- Migrate ChatRequestBloc → ChatRequestNotifier (WebSocket incoming requests)
- Migrate MitraChatBloc → MitraChatNotifier (WebSocket chat + messages)
- Update router to use Riverpod auth state for redirects
- Remove all flutter_bloc usage from mitra_app screens and main.dart
- MultiBlocProvider fully removed from mitra_app

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 14:08:45 +08:00
parent bc66bbf50a
commit 35d470b851
17 changed files with 1298 additions and 461 deletions

View File

@@ -0,0 +1,114 @@
import 'dart:async';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../api/api_client.dart';
import '../api/api_client_provider.dart';
part 'auth_notifier.g.dart';
// States
sealed class MitraAuthData {
const MitraAuthData();
}
class MitraAuthInitialData extends MitraAuthData {
const MitraAuthInitialData();
}
class MitraAuthAuthenticatedData extends MitraAuthData {
final Map<String, dynamic> profile;
const MitraAuthAuthenticatedData(this.profile);
}
class MitraAuthOtpSentData extends MitraAuthData {
final String verificationId;
const MitraAuthOtpSentData(this.verificationId);
}
@Riverpod(keepAlive: true)
class MitraAuth extends _$MitraAuth {
FirebaseAuth get _auth => FirebaseAuth.instance;
ApiClient get _apiClient => ref.read(apiClientProvider);
ConfirmationResult? _webConfirmationResult;
@override
FutureOr<MitraAuthData> build() async {
if (_auth.currentUser != null) {
return await _verifyAndReturn();
}
return const MitraAuthInitialData(); // FIX: was missing in BLoC version
}
Future<void> requestOtp(String phone) async {
state = const AsyncLoading();
if (kIsWeb) {
try {
final confirmationResult = await _auth.signInWithPhoneNumber(phone);
_webConfirmationResult = confirmationResult;
state = const AsyncData(MitraAuthOtpSentData('web'));
} catch (e) {
state = AsyncError('Gagal mengirim OTP. Coba lagi.', StackTrace.current);
}
} else {
final completer = Completer<void>();
await _auth.verifyPhoneNumber(
phoneNumber: phone,
verificationCompleted: (_) {
if (!completer.isCompleted) completer.complete();
},
verificationFailed: (e) {
state = AsyncError('Gagal mengirim OTP. Coba lagi.', StackTrace.current);
if (!completer.isCompleted) completer.complete();
},
codeSent: (verificationId, _) {
state = AsyncData(MitraAuthOtpSentData(verificationId));
if (!completer.isCompleted) completer.complete();
},
codeAutoRetrievalTimeout: (_) {
if (!completer.isCompleted) completer.complete();
},
);
await completer.future;
}
}
Future<void> verifyOtp(String verificationId, String smsCode) async {
state = const AsyncLoading();
try {
if (kIsWeb && _webConfirmationResult != null) {
await _webConfirmationResult!.confirm(smsCode);
} else {
final credential = PhoneAuthProvider.credential(
verificationId: verificationId,
smsCode: smsCode,
);
await _auth.signInWithCredential(credential);
}
state = AsyncData(await _verifyAndReturn());
} catch (e) {
state = AsyncError('OTP tidak valid. Coba lagi.', StackTrace.current);
}
}
Future<void> logout() async {
await _auth.signOut();
state = const AsyncData(MitraAuthInitialData());
}
Future<MitraAuthData> _verifyAndReturn() async {
try {
final response = await _apiClient.post('/api/mitra/auth/verify');
return MitraAuthAuthenticatedData(response['data'] as Map<String, dynamic>);
} on Exception catch (e) {
await _auth.signOut();
final msg = e.toString();
if (msg.contains('ACCOUNT_NOT_FOUND')) {
throw Exception('Akun tidak ditemukan. Hubungi administrator.');
} else if (msg.contains('ACCOUNT_INACTIVE')) {
throw Exception('Akun tidak aktif. Hubungi administrator.');
}
throw Exception('Gagal masuk. Coba lagi.');
}
}
}

View File

@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'auth_notifier.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$mitraAuthHash() => r'65235a41cde3a37feef0b3004a0a48b508bf9ac9';
/// See also [MitraAuth].
@ProviderFor(MitraAuth)
final mitraAuthProvider =
AsyncNotifierProvider<MitraAuth, MitraAuthData>.internal(
MitraAuth.new,
name: r'mitraAuthProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$mitraAuthHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$MitraAuth = AsyncNotifier<MitraAuthData>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -0,0 +1,148 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../api/api_client.dart';
import '../api/api_client_provider.dart';
import '../constants.dart';
part 'chat_request_notifier.g.dart';
// States
sealed class ChatRequestData {
const ChatRequestData();
}
class ChatRequestIdleData extends ChatRequestData {
const ChatRequestIdleData();
}
class ChatRequestListeningData extends ChatRequestData {
const ChatRequestListeningData();
}
class ChatRequestIncomingData extends ChatRequestData {
final String sessionId;
const ChatRequestIncomingData(this.sessionId);
}
class ChatRequestAcceptingData extends ChatRequestData {
const ChatRequestAcceptingData();
}
class ChatRequestAcceptedData extends ChatRequestData {
final Map<String, dynamic> session;
const ChatRequestAcceptedData(this.session);
}
class ChatRequestErrorData extends ChatRequestData {
final String message;
const ChatRequestErrorData(this.message);
}
@Riverpod(keepAlive: true)
class ChatRequest extends _$ChatRequest {
WebSocketChannel? _channel;
StreamSubscription? _wsSubscription;
ApiClient get _apiClient => ref.read(apiClientProvider);
@override
ChatRequestData build() => const ChatRequestIdleData();
Future<void> startListening() async {
_closeWebSocket();
state = const ChatRequestListeningData();
await _connectWebSocket();
}
void stopListening() {
_closeWebSocket();
state = const ChatRequestIdleData();
}
Future<void> _connectWebSocket() async {
try {
final user = FirebaseAuth.instance.currentUser;
if (user == null) return;
final token = await user.getIdToken();
final wsUrl = ApiClient.baseUrl
.replaceFirst('https://', 'wss://')
.replaceFirst('http://', 'ws://');
_channel = WebSocketChannel.connect(Uri.parse('$wsUrl/api/shared/ws'));
_wsSubscription = _channel!.stream.listen(
(raw) {
final data = jsonDecode(raw as String) as Map<String, dynamic>;
if (data['type'] == WsMessage.authOk) return;
_onRequestReceived(data);
},
onError: (_) => _onConnectionError(),
onDone: () => _onConnectionError(),
);
_channel!.sink.add(jsonEncode({
'type': WsMessage.auth,
'token': token,
}));
} catch (_) {
_onConnectionError();
}
}
void _onConnectionError() {
_closeWebSocket();
if (state is! ChatRequestIdleData) {
state = const ChatRequestListeningData();
}
}
void _onRequestReceived(Map<String, dynamic> data) {
final type = data['type'] as String?;
if (type == WsMessage.chatRequest) {
state = ChatRequestIncomingData(data['session_id'] as String);
} else if (type == WsMessage.chatRequestClosed) {
if (state is ChatRequestIncomingData) {
state = const ChatRequestListeningData();
}
} else if (type == 'session_rerouted') {
state = const ChatRequestListeningData();
} else if (type == 'session_assigned') {
state = ChatRequestAcceptedData({'session_id': data['session_id']});
}
}
Future<void> accept(String sessionId) async {
state = const ChatRequestAcceptingData();
try {
final response = await _apiClient.post('/api/mitra/chat-requests/$sessionId/accept');
state = ChatRequestAcceptedData(response['data'] as Map<String, dynamic>);
} on DioException catch (e) {
final code = e.response?.data?['error']?['code'];
if (code == 'REQUEST_UNAVAILABLE') {
state = const ChatRequestListeningData();
} else {
state = const ChatRequestErrorData('Gagal menerima. Coba lagi.');
}
}
}
Future<void> decline(String sessionId) async {
try {
await _apiClient.post('/api/mitra/chat-requests/$sessionId/decline');
} catch (_) {}
state = const ChatRequestListeningData();
}
void _closeWebSocket() {
_wsSubscription?.cancel();
_wsSubscription = null;
_channel?.sink.close();
_channel = null;
}
}

View File

@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'chat_request_notifier.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$chatRequestHash() => r'b99836c687e861493c432ff5a5901a70f24ab1c7';
/// See also [ChatRequest].
@ProviderFor(ChatRequest)
final chatRequestProvider =
NotifierProvider<ChatRequest, ChatRequestData>.internal(
ChatRequest.new,
name: r'chatRequestProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$chatRequestHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$ChatRequest = Notifier<ChatRequestData>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -0,0 +1,79 @@
import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../api/api_client_provider.dart';
part 'extension_notifier.g.dart';
// States
sealed class ExtensionData {
const ExtensionData();
}
class ExtensionIdleData extends ExtensionData {
const ExtensionIdleData();
}
class ExtensionRespondingData extends ExtensionData {
const ExtensionRespondingData();
}
class ExtensionShowGoodbyeData extends ExtensionData {
const ExtensionShowGoodbyeData();
}
class ExtensionSubmittingData extends ExtensionData {
const ExtensionSubmittingData();
}
class ExtensionCompleteData extends ExtensionData {
const ExtensionCompleteData();
}
class ExtensionErrorData extends ExtensionData {
final String message;
const ExtensionErrorData(this.message);
}
@Riverpod(keepAlive: true)
class MitraExtension extends _$MitraExtension {
@override
ExtensionData build() => const ExtensionIdleData();
Future<void> respond(String sessionId, {required String extensionId, required bool accepted}) async {
state = const ExtensionRespondingData();
try {
await ref.read(apiClientProvider).post('/api/mitra/chat-requests/sessions/$sessionId/extend-response', data: {
'extension_id': extensionId,
'accepted': accepted,
});
if (!accepted) {
state = const ExtensionShowGoodbyeData();
} else {
state = const ExtensionIdleData();
}
} on DioException catch (e) {
final code = e.response?.data?['error']?['code'];
if (code == 'EXTENSION_RESOLVED') {
state = const ExtensionShowGoodbyeData();
} else {
state = const ExtensionErrorData('Gagal merespon perpanjangan.');
}
}
}
Future<void> submitGoodbye(String sessionId, String message) async {
state = const ExtensionSubmittingData();
try {
await ref.read(apiClientProvider).post('/api/shared/sessions/$sessionId/close-message', data: {
'message': message,
});
state = const ExtensionCompleteData();
} catch (e) {
state = const ExtensionErrorData('Gagal mengirim pesan penutup.');
}
}
void reset() {
state = const ExtensionIdleData();
}
}

View File

@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'extension_notifier.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$mitraExtensionHash() => r'4eed73b51454238e2cd40a255c148f232f281913';
/// See also [MitraExtension].
@ProviderFor(MitraExtension)
final mitraExtensionProvider =
NotifierProvider<MitraExtension, ExtensionData>.internal(
MitraExtension.new,
name: r'mitraExtensionProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$mitraExtensionHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$MitraExtension = Notifier<ExtensionData>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -0,0 +1,302 @@
import 'dart:async';
import 'dart:convert';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../api/api_client.dart';
import '../api/api_client_provider.dart';
import '../constants.dart';
part 'mitra_chat_notifier.g.dart';
// States
sealed class MitraChatData {
const MitraChatData();
}
class MitraChatInitialData extends MitraChatData {
const MitraChatInitialData();
}
class MitraChatConnectingData extends MitraChatData {
const MitraChatConnectingData();
}
class MitraChatConnectedData extends MitraChatData {
final List<MitraChatMessage> messages;
final bool isOtherTyping;
final int? remainingSeconds;
final bool sessionExpired;
final bool sessionClosing;
final Map<String, dynamic>? extensionRequest;
const MitraChatConnectedData({
required this.messages,
this.isOtherTyping = false,
this.remainingSeconds,
this.sessionExpired = false,
this.sessionClosing = false,
this.extensionRequest,
});
MitraChatConnectedData copyWith({
List<MitraChatMessage>? messages,
bool? isOtherTyping,
int? remainingSeconds,
bool? sessionExpired,
bool? sessionClosing,
Map<String, dynamic>? extensionRequest,
bool clearExtensionRequest = false,
}) {
return MitraChatConnectedData(
messages: messages ?? this.messages,
isOtherTyping: isOtherTyping ?? this.isOtherTyping,
remainingSeconds: remainingSeconds ?? this.remainingSeconds,
sessionExpired: sessionExpired ?? this.sessionExpired,
sessionClosing: sessionClosing ?? this.sessionClosing,
extensionRequest: clearExtensionRequest ? null : (extensionRequest ?? this.extensionRequest),
);
}
}
class MitraChatErrorData extends MitraChatData {
final String message;
const MitraChatErrorData(this.message);
}
// Message model
class MitraChatMessage {
final String id;
final String senderType;
final String content;
final String type;
final String status;
final DateTime createdAt;
const MitraChatMessage({
required this.id,
required this.senderType,
required this.content,
this.type = MessageType.text,
this.status = MessageStatus.sent,
required this.createdAt,
});
MitraChatMessage copyWith({String? status}) {
return MitraChatMessage(
id: id,
senderType: senderType,
content: content,
type: type,
status: status ?? this.status,
createdAt: createdAt,
);
}
}
@Riverpod(keepAlive: true)
class MitraChat extends _$MitraChat {
WebSocketChannel? _channel;
StreamSubscription? _wsSubscription;
Timer? _typingTimer;
ApiClient get _apiClient => ref.read(apiClientProvider);
@override
MitraChatData build() => const MitraChatInitialData();
Future<void> connect(String sessionId) async {
state = const MitraChatConnectingData();
try {
final sessionInfo = await _apiClient.get('/api/shared/chat/$sessionId/info');
final sessionData = sessionInfo['data'] as Map<String, dynamic>?;
final sessionStatus = sessionData?['status'] as String?;
if (sessionStatus == SessionStatus.completed ||
sessionStatus == SessionStatus.cancelled ||
sessionStatus == SessionStatus.expired) {
state = const MitraChatErrorData('Sesi sudah berakhir.');
return;
}
final isClosing = sessionStatus == SessionStatus.closing;
final response = await _apiClient.get('/api/shared/chat/$sessionId/messages');
final messagesData = response['data'] as List<dynamic>;
final messages = messagesData.map((m) => MitraChatMessage(
id: m['id'] as String,
senderType: m['sender_type'] as String,
content: m['content'] as String,
type: m['type'] as String? ?? MessageType.text,
status: m['status'] as String? ?? MessageStatus.sent,
createdAt: DateTime.parse(m['created_at'] as String),
)).toList();
final user = FirebaseAuth.instance.currentUser;
final token = await user?.getIdToken();
final wsUrl = ApiClient.baseUrl
.replaceFirst('https://', 'wss://')
.replaceFirst('http://', 'ws://');
_channel = WebSocketChannel.connect(Uri.parse('$wsUrl/api/shared/ws'));
_wsSubscription = _channel!.stream.listen(
(raw) {
final data = jsonDecode(raw as String) as Map<String, dynamic>;
_onMessageReceived(data);
},
onError: (_) {},
onDone: () {},
);
_channel!.sink.add(jsonEncode({
'type': WsMessage.auth,
'token': token,
'session_id': sessionId,
}));
state = MitraChatConnectedData(messages: messages, sessionClosing: isClosing);
} catch (e) {
state = const MitraChatErrorData('Gagal terhubung ke chat.');
}
}
void disconnect() {
_cleanup();
state = const MitraChatInitialData();
}
void sendMessage(String content) {
if (state is! MitraChatConnectedData || _channel == null) return;
final current = state as MitraChatConnectedData;
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
final msg = MitraChatMessage(
id: tempId,
senderType: UserType.mitra,
content: content,
status: 'sending',
createdAt: DateTime.now(),
);
state = current.copyWith(messages: [...current.messages, msg]);
_channel!.sink.add(jsonEncode({
'type': WsMessage.message,
'content': content,
'_temp_id': tempId,
}));
}
void sendTyping() {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({'type': WsMessage.typing}));
}
void markDelivered(List<String> messageIds) {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({'type': WsMessage.delivered, 'message_ids': messageIds}));
}
void markRead(List<String> messageIds) {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({'type': WsMessage.read, 'message_ids': messageIds}));
}
void _onMessageReceived(Map<String, dynamic> data) {
if (state is! MitraChatConnectedData) return;
final current = state as MitraChatConnectedData;
final type = data['type'] as String?;
switch (type) {
case WsMessage.authOk:
break;
case WsMessage.message:
final msg = MitraChatMessage(
id: data['message_id'] as String,
senderType: data['sender_type'] as String,
content: data['content'] as String,
type: data['message_type'] as String? ?? MessageType.text,
status: MessageStatus.sent,
createdAt: DateTime.parse(data['created_at'] as String),
);
state = current.copyWith(messages: [...current.messages, msg]);
markDelivered([msg.id]);
break;
case WsMessage.messageAck:
final messageId = data['message_id'] as String;
final status = data['status'] as String;
final updatedMessages = current.messages.map((m) {
if (m.status == 'sending') return m.copyWith(status: status);
return m;
}).toList();
final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == UserType.mitra);
if (idx >= 0) {
final old = updatedMessages[idx];
updatedMessages[idx] = MitraChatMessage(
id: messageId,
senderType: old.senderType,
content: old.content,
type: old.type,
status: status,
createdAt: old.createdAt,
);
}
state = current.copyWith(messages: updatedMessages);
break;
case WsMessage.messageStatus:
final messageIds = (data['message_ids'] as List<dynamic>).cast<String>();
final status = data['status'] as String;
final updatedMessages = current.messages.map((m) {
if (messageIds.contains(m.id)) return m.copyWith(status: status);
return m;
}).toList();
state = current.copyWith(messages: updatedMessages);
break;
case WsMessage.typing:
state = current.copyWith(isOtherTyping: true);
_typingTimer?.cancel();
_typingTimer = Timer(const Duration(seconds: 3), () {
if (state is MitraChatConnectedData) {
state = (state as MitraChatConnectedData).copyWith(isOtherTyping: false);
}
});
break;
case WsMessage.sessionTimer:
state = current.copyWith(remainingSeconds: data['remaining_seconds'] as int?);
break;
case WsMessage.sessionExpired:
state = current.copyWith(sessionExpired: true);
break;
case WsMessage.extensionRequest:
state = current.copyWith(extensionRequest: data);
break;
case WsMessage.sessionResumed:
state = current.copyWith(sessionExpired: false, sessionClosing: false, clearExtensionRequest: true);
break;
case WsMessage.sessionClosing:
state = current.copyWith(sessionClosing: true, clearExtensionRequest: true);
break;
case WsMessage.sessionCompleted:
_cleanup();
break;
}
}
void _cleanup() {
_wsSubscription?.cancel();
_wsSubscription = null;
_channel?.sink.close();
_channel = null;
_typingTimer?.cancel();
_typingTimer = null;
}
}

View File

@@ -0,0 +1,24 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'mitra_chat_notifier.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$mitraChatHash() => r'827aa874dbcf49c17f94c0507f5e0a4064bcede3';
/// See also [MitraChat].
@ProviderFor(MitraChat)
final mitraChatProvider = NotifierProvider<MitraChat, MitraChatData>.internal(
MitraChat.new,
name: r'mitraChatProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$mitraChatHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$MitraChat = Notifier<MitraChatData>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -0,0 +1,97 @@
import 'dart:async';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../api/api_client_provider.dart';
part 'status_notifier.g.dart';
// States
sealed class OnlineStatusData {
const OnlineStatusData();
}
class StatusInitialData extends OnlineStatusData {
const StatusInitialData();
}
class StatusLoadedData extends OnlineStatusData {
final bool isOnline;
const StatusLoadedData({required this.isOnline});
}
class StatusLoadingData extends OnlineStatusData {
const StatusLoadingData();
}
class StatusErrorData extends OnlineStatusData {
final String message;
const StatusErrorData(this.message);
}
@Riverpod(keepAlive: true)
class OnlineStatus extends _$OnlineStatus {
Timer? _heartbeatTimer;
@override
OnlineStatusData build() => const StatusInitialData();
Future<void> load() async {
try {
final response = await ref.read(apiClientProvider).get('/api/mitra/status');
final data = response['data'] as Map<String, dynamic>;
state = StatusLoadedData(isOnline: data['is_online'] as bool);
} catch (e) {
state = const StatusLoadedData(isOnline: false);
}
}
Future<void> toggleOnline() async {
state = const StatusLoadingData();
try {
await ref.read(apiClientProvider).post('/api/mitra/status/online');
_startHeartbeat();
state = const StatusLoadedData(isOnline: true);
} catch (e) {
state = const StatusErrorData('Gagal mengubah status. Coba lagi.');
}
}
Future<void> toggleOffline() async {
state = const StatusLoadingData();
try {
await ref.read(apiClientProvider).post('/api/mitra/status/offline');
_stopHeartbeat();
state = const StatusLoadedData(isOnline: false);
} catch (e) {
state = const StatusErrorData('Gagal mengubah status. Coba lagi.');
}
}
void onAppPaused() {
_stopHeartbeat();
}
void onAppResumed() {
if (state is StatusLoadedData && (state as StatusLoadedData).isOnline) {
_startHeartbeat();
}
load();
}
void _startHeartbeat() {
_stopHeartbeat();
_heartbeatTimer = Timer.periodic(const Duration(seconds: 15), (_) {
_heartbeatTick();
});
}
void _stopHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
}
Future<void> _heartbeatTick() async {
try {
await ref.read(apiClientProvider).post('/api/mitra/status/heartbeat');
} catch (_) {}
}
}

View File

@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'status_notifier.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$onlineStatusHash() => r'6b42328eaba0f7934b0e3eaa54eb6b764f1c4e53';
/// See also [OnlineStatus].
@ProviderFor(OnlineStatus)
final onlineStatusProvider =
NotifierProvider<OnlineStatus, OnlineStatusData>.internal(
OnlineStatus.new,
name: r'onlineStatusProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$onlineStatusHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$OnlineStatus = Notifier<OnlineStatusData>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package