Phase 3.1: Complete client_app Riverpod migration (all blocs)
- Migrate SessionClosureBloc → SessionClosureNotifier (@riverpod) - Migrate PairingBloc → PairingNotifier (@riverpod, WebSocket + timer) - Migrate ChatBloc → ChatNotifier (@riverpod, WebSocket + message state) - Remove all flutter_bloc usage from client_app screens and main.dart - MultiBlocProvider fully removed from client_app - All screens now use ConsumerWidget/ConsumerStatefulWidget + ref Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
332
client_app/lib/core/chat/chat_notifier.dart
Normal file
332
client_app/lib/core/chat/chat_notifier.dart
Normal file
@@ -0,0 +1,332 @@
|
||||
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 'chat_notifier.g.dart';
|
||||
|
||||
// States
|
||||
sealed class ChatData {
|
||||
const ChatData();
|
||||
}
|
||||
|
||||
class ChatInitialData extends ChatData {
|
||||
const ChatInitialData();
|
||||
}
|
||||
|
||||
class ChatConnectingData extends ChatData {
|
||||
const ChatConnectingData();
|
||||
}
|
||||
|
||||
class ChatConnectedData extends ChatData {
|
||||
final List<ChatMessage> messages;
|
||||
final bool isOtherTyping;
|
||||
final int? remainingSeconds;
|
||||
final bool sessionExpired;
|
||||
final bool sessionPaused;
|
||||
final bool sessionClosing;
|
||||
final Map<String, dynamic>? extensionResponse;
|
||||
|
||||
const ChatConnectedData({
|
||||
required this.messages,
|
||||
this.isOtherTyping = false,
|
||||
this.remainingSeconds,
|
||||
this.sessionExpired = false,
|
||||
this.sessionPaused = false,
|
||||
this.sessionClosing = false,
|
||||
this.extensionResponse,
|
||||
});
|
||||
|
||||
ChatConnectedData copyWith({
|
||||
List<ChatMessage>? messages,
|
||||
bool? isOtherTyping,
|
||||
int? remainingSeconds,
|
||||
bool? sessionExpired,
|
||||
bool? sessionPaused,
|
||||
bool? sessionClosing,
|
||||
Map<String, dynamic>? extensionResponse,
|
||||
}) {
|
||||
return ChatConnectedData(
|
||||
messages: messages ?? this.messages,
|
||||
isOtherTyping: isOtherTyping ?? this.isOtherTyping,
|
||||
remainingSeconds: remainingSeconds ?? this.remainingSeconds,
|
||||
sessionExpired: sessionExpired ?? this.sessionExpired,
|
||||
sessionPaused: sessionPaused ?? this.sessionPaused,
|
||||
sessionClosing: sessionClosing ?? this.sessionClosing,
|
||||
extensionResponse: extensionResponse ?? this.extensionResponse,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChatErrorData extends ChatData {
|
||||
final String message;
|
||||
const ChatErrorData(this.message);
|
||||
}
|
||||
|
||||
// Message model
|
||||
class ChatMessage {
|
||||
final String id;
|
||||
final String senderType;
|
||||
final String content;
|
||||
final String type;
|
||||
final String status;
|
||||
final DateTime createdAt;
|
||||
|
||||
const ChatMessage({
|
||||
required this.id,
|
||||
required this.senderType,
|
||||
required this.content,
|
||||
this.type = MessageType.text,
|
||||
this.status = MessageStatus.sent,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
ChatMessage copyWith({String? status}) {
|
||||
return ChatMessage(
|
||||
id: id,
|
||||
senderType: senderType,
|
||||
content: content,
|
||||
type: type,
|
||||
status: status ?? this.status,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class Chat extends _$Chat {
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription? _wsSubscription;
|
||||
Timer? _typingTimer;
|
||||
|
||||
ApiClient get _apiClient => ref.read(apiClientProvider);
|
||||
|
||||
@override
|
||||
ChatData build() => const ChatInitialData();
|
||||
|
||||
Future<void> connect(String sessionId) async {
|
||||
state = const ChatConnectingData();
|
||||
|
||||
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 ChatErrorData('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) => ChatMessage(
|
||||
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 = ChatConnectedData(
|
||||
messages: messages,
|
||||
sessionClosing: isClosing,
|
||||
);
|
||||
} catch (e) {
|
||||
state = const ChatErrorData('Gagal terhubung ke chat.');
|
||||
}
|
||||
}
|
||||
|
||||
void disconnect() {
|
||||
_cleanup();
|
||||
state = const ChatInitialData();
|
||||
}
|
||||
|
||||
void sendMessage(String content) {
|
||||
if (state is! ChatConnectedData || _channel == null) return;
|
||||
final current = state as ChatConnectedData;
|
||||
|
||||
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
|
||||
final msg = ChatMessage(
|
||||
id: tempId,
|
||||
senderType: UserType.customer,
|
||||
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! ChatConnectedData) return;
|
||||
final current = state as ChatConnectedData;
|
||||
final type = data['type'] as String?;
|
||||
|
||||
switch (type) {
|
||||
case WsMessage.authOk:
|
||||
break;
|
||||
|
||||
case WsMessage.message:
|
||||
final msg = ChatMessage(
|
||||
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.customer);
|
||||
if (idx >= 0) {
|
||||
final old = updatedMessages[idx];
|
||||
updatedMessages[idx] = ChatMessage(
|
||||
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 ChatConnectedData) {
|
||||
state = (state as ChatConnectedData).copyWith(isOtherTyping: false);
|
||||
}
|
||||
});
|
||||
break;
|
||||
|
||||
case WsMessage.sessionTimer:
|
||||
final remaining = data['remaining_seconds'] as int?;
|
||||
state = current.copyWith(remainingSeconds: remaining);
|
||||
break;
|
||||
|
||||
case WsMessage.sessionExpired:
|
||||
state = current.copyWith(sessionExpired: true);
|
||||
break;
|
||||
|
||||
case WsMessage.sessionPaused:
|
||||
state = current.copyWith(sessionPaused: true);
|
||||
break;
|
||||
|
||||
case WsMessage.sessionResumed:
|
||||
state = current.copyWith(sessionPaused: false, sessionExpired: false, sessionClosing: false);
|
||||
break;
|
||||
|
||||
case WsMessage.sessionClosing:
|
||||
state = current.copyWith(sessionClosing: true);
|
||||
break;
|
||||
|
||||
case WsMessage.extensionResponse:
|
||||
final accepted = data['accepted'] as bool? ?? false;
|
||||
state = current.copyWith(
|
||||
extensionResponse: data,
|
||||
sessionPaused: accepted ? false : current.sessionPaused,
|
||||
sessionExpired: accepted ? false : current.sessionExpired,
|
||||
);
|
||||
break;
|
||||
|
||||
case WsMessage.sessionCompleted:
|
||||
_cleanup();
|
||||
break;
|
||||
|
||||
case WsMessage.error:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _cleanup() {
|
||||
_wsSubscription?.cancel();
|
||||
_wsSubscription = null;
|
||||
_channel?.sink.close();
|
||||
_channel = null;
|
||||
_typingTimer?.cancel();
|
||||
_typingTimer = null;
|
||||
}
|
||||
}
|
||||
24
client_app/lib/core/chat/chat_notifier.g.dart
Normal file
24
client_app/lib/core/chat/chat_notifier.g.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'chat_notifier.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$chatHash() => r'c67d0e916a9474e5142d1f07649792cd448607e4';
|
||||
|
||||
/// See also [Chat].
|
||||
@ProviderFor(Chat)
|
||||
final chatProvider = NotifierProvider<Chat, ChatData>.internal(
|
||||
Chat.new,
|
||||
name: r'chatProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$chatHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$Chat = Notifier<ChatData>;
|
||||
// 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
|
||||
74
client_app/lib/core/chat/session_closure_notifier.dart
Normal file
74
client_app/lib/core/chat/session_closure_notifier.dart
Normal file
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import '../api/api_client_provider.dart';
|
||||
|
||||
part 'session_closure_notifier.g.dart';
|
||||
|
||||
// States
|
||||
sealed class SessionClosureData {
|
||||
const SessionClosureData();
|
||||
}
|
||||
|
||||
class ClosureInitialData extends SessionClosureData {
|
||||
const ClosureInitialData();
|
||||
}
|
||||
|
||||
class ExtendingWaitingMitraData extends SessionClosureData {
|
||||
const ExtendingWaitingMitraData();
|
||||
}
|
||||
|
||||
class ClosureShowGoodbyeData extends SessionClosureData {
|
||||
const ClosureShowGoodbyeData();
|
||||
}
|
||||
|
||||
class ClosureSubmittingData extends SessionClosureData {
|
||||
const ClosureSubmittingData();
|
||||
}
|
||||
|
||||
class ClosureCompleteData extends SessionClosureData {
|
||||
const ClosureCompleteData();
|
||||
}
|
||||
|
||||
class ClosureErrorData extends SessionClosureData {
|
||||
final String message;
|
||||
const ClosureErrorData(this.message);
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class SessionClosure extends _$SessionClosure {
|
||||
@override
|
||||
SessionClosureData build() => const ClosureInitialData();
|
||||
|
||||
Future<void> requestExtension(String sessionId, {required int durationMinutes, required int price}) async {
|
||||
state = const ExtendingWaitingMitraData();
|
||||
try {
|
||||
await ref.read(apiClientProvider).post('/api/client/chat/session/$sessionId/extend', data: {
|
||||
'duration_minutes': durationMinutes,
|
||||
'price': price,
|
||||
});
|
||||
// Response will come via WebSocket (ChatBloc/ChatNotifier handles it)
|
||||
} catch (e) {
|
||||
state = const ClosureErrorData('Gagal meminta perpanjangan.');
|
||||
}
|
||||
}
|
||||
|
||||
void declineExtension() {
|
||||
state = const ClosureShowGoodbyeData();
|
||||
}
|
||||
|
||||
void reset() {
|
||||
state = const ClosureInitialData();
|
||||
}
|
||||
|
||||
Future<void> submitGoodbye(String sessionId, String message) async {
|
||||
state = const ClosureSubmittingData();
|
||||
try {
|
||||
await ref.read(apiClientProvider).post('/api/shared/sessions/$sessionId/close-message', data: {
|
||||
'message': message,
|
||||
});
|
||||
state = const ClosureCompleteData();
|
||||
} catch (e) {
|
||||
state = const ClosureErrorData('Gagal mengirim pesan penutup.');
|
||||
}
|
||||
}
|
||||
}
|
||||
26
client_app/lib/core/chat/session_closure_notifier.g.dart
Normal file
26
client_app/lib/core/chat/session_closure_notifier.g.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'session_closure_notifier.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$sessionClosureHash() => r'5799a386e1e9c925601567b1fb8c684be7c7e23c';
|
||||
|
||||
/// See also [SessionClosure].
|
||||
@ProviderFor(SessionClosure)
|
||||
final sessionClosureProvider =
|
||||
NotifierProvider<SessionClosure, SessionClosureData>.internal(
|
||||
SessionClosure.new,
|
||||
name: r'sessionClosureProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$sessionClosureHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$SessionClosure = Notifier<SessionClosureData>;
|
||||
// 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
|
||||
183
client_app/lib/core/pairing/pairing_notifier.dart
Normal file
183
client_app/lib/core/pairing/pairing_notifier.dart
Normal file
@@ -0,0 +1,183 @@
|
||||
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 'pairing_notifier.g.dart';
|
||||
|
||||
// States
|
||||
sealed class PairingData {
|
||||
const PairingData();
|
||||
}
|
||||
|
||||
class PairingInitialData extends PairingData {
|
||||
const PairingInitialData();
|
||||
}
|
||||
|
||||
class PairingSearchingData extends PairingData {
|
||||
final String sessionId;
|
||||
const PairingSearchingData(this.sessionId);
|
||||
}
|
||||
|
||||
class PairingBestieFoundData extends PairingData {
|
||||
final String sessionId;
|
||||
final String mitraName;
|
||||
const PairingBestieFoundData({required this.sessionId, required this.mitraName});
|
||||
}
|
||||
|
||||
class PairingActiveData extends PairingData {
|
||||
final String sessionId;
|
||||
final String mitraName;
|
||||
const PairingActiveData({required this.sessionId, required this.mitraName});
|
||||
}
|
||||
|
||||
class PairingNoBestieData extends PairingData {
|
||||
const PairingNoBestieData();
|
||||
}
|
||||
|
||||
class PairingCancelledData extends PairingData {
|
||||
const PairingCancelledData();
|
||||
}
|
||||
|
||||
class PairingErrorData extends PairingData {
|
||||
final String message;
|
||||
const PairingErrorData(this.message);
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class Pairing extends _$Pairing {
|
||||
Timer? _timeoutTimer;
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription? _wsSubscription;
|
||||
|
||||
ApiClient get _apiClient => ref.read(apiClientProvider);
|
||||
|
||||
@override
|
||||
PairingData build() => const PairingInitialData();
|
||||
|
||||
Future<void> requestPairing() async {
|
||||
await _doPairingRequest({});
|
||||
}
|
||||
|
||||
Future<void> requestPairingWithTier({int? durationMinutes, int? price, bool isFreeTrial = false}) async {
|
||||
final body = <String, dynamic>{};
|
||||
if (isFreeTrial) {
|
||||
body['is_free_trial'] = true;
|
||||
} else {
|
||||
body['duration_minutes'] = durationMinutes;
|
||||
body['price'] = price;
|
||||
}
|
||||
await _doPairingRequest(body);
|
||||
}
|
||||
|
||||
Future<void> _doPairingRequest(Map<String, dynamic> body) async {
|
||||
if (state is! PairingInitialData) {
|
||||
state = const PairingInitialData();
|
||||
}
|
||||
try {
|
||||
await _connectWebSocket();
|
||||
|
||||
final response = await _apiClient.post('/api/client/chat/request', data: body);
|
||||
final data = response['data'] as Map<String, dynamic>;
|
||||
final sessionId = data['id'] as String;
|
||||
|
||||
state = PairingSearchingData(sessionId);
|
||||
|
||||
_timeoutTimer = Timer(const Duration(seconds: 60), () {
|
||||
_cleanup();
|
||||
state = const PairingNoBestieData();
|
||||
});
|
||||
} on DioException catch (e) {
|
||||
_cleanup();
|
||||
final code = e.response?.data?['error']?['code'];
|
||||
if (code == 'NO_MITRA_AVAILABLE') {
|
||||
state = const PairingNoBestieData();
|
||||
} else if (code == 'ALREADY_ACTIVE') {
|
||||
state = const PairingErrorData('Kamu sudah memiliki sesi aktif.');
|
||||
} else if (code == 'FREE_TRIAL_INELIGIBLE') {
|
||||
state = const PairingErrorData('Kamu tidak memenuhi syarat untuk free trial.');
|
||||
} else {
|
||||
state = const PairingErrorData('Gagal memulai. Coba lagi.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connectWebSocket() async {
|
||||
_closeWebSocket();
|
||||
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;
|
||||
_onStatusUpdate(data);
|
||||
},
|
||||
onError: (_) {},
|
||||
onDone: () {},
|
||||
);
|
||||
|
||||
_channel!.sink.add(jsonEncode({
|
||||
'type': WsMessage.auth,
|
||||
'token': token,
|
||||
}));
|
||||
}
|
||||
|
||||
Future<void> _onStatusUpdate(Map<String, dynamic> data) async {
|
||||
final type = data['type'] as String?;
|
||||
|
||||
if (type == WsMessage.paired) {
|
||||
_cleanup();
|
||||
final mitraName = data['mitra_display_name'] as String? ?? 'Bestie';
|
||||
final sessionId = data['session_id'] as String;
|
||||
state = PairingBestieFoundData(sessionId: sessionId, mitraName: mitraName);
|
||||
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
state = PairingActiveData(sessionId: sessionId, mitraName: mitraName);
|
||||
} else if (type == SessionStatus.expired) {
|
||||
_cleanup();
|
||||
state = const PairingNoBestieData();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelPairing() async {
|
||||
if (state is PairingSearchingData) {
|
||||
final sessionId = (state as PairingSearchingData).sessionId;
|
||||
try {
|
||||
await _apiClient.post('/api/client/chat/request/$sessionId/cancel');
|
||||
} catch (_) {}
|
||||
_cleanup();
|
||||
state = const PairingCancelledData();
|
||||
}
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_cleanup();
|
||||
state = const PairingInitialData();
|
||||
}
|
||||
|
||||
void _closeWebSocket() {
|
||||
_wsSubscription?.cancel();
|
||||
_wsSubscription = null;
|
||||
_channel?.sink.close();
|
||||
_channel = null;
|
||||
}
|
||||
|
||||
void _cleanup() {
|
||||
_timeoutTimer?.cancel();
|
||||
_timeoutTimer = null;
|
||||
_closeWebSocket();
|
||||
}
|
||||
}
|
||||
24
client_app/lib/core/pairing/pairing_notifier.g.dart
Normal file
24
client_app/lib/core/pairing/pairing_notifier.g.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'pairing_notifier.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$pairingHash() => r'93049804c1d55a0195a56b97d6e7f34fe6ab8086';
|
||||
|
||||
/// See also [Pairing].
|
||||
@ProviderFor(Pairing)
|
||||
final pairingProvider = NotifierProvider<Pairing, PairingData>.internal(
|
||||
Pairing.new,
|
||||
name: r'pairingProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$pairingHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$Pairing = Notifier<PairingData>;
|
||||
// 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,9 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/pairing/pairing_bloc.dart';
|
||||
import '../../../core/pairing/pairing_notifier.dart';
|
||||
|
||||
class BestieFoundScreen extends StatelessWidget {
|
||||
class BestieFoundScreen extends ConsumerWidget {
|
||||
final String sessionId;
|
||||
final String mitraName;
|
||||
|
||||
@@ -14,14 +14,14 @@ class BestieFoundScreen extends StatelessWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<PairingBloc, PairingState>(
|
||||
listener: (context, state) {
|
||||
if (state is PairingActive) {
|
||||
context.go('/chat/session/${state.sessionId}', extra: state.mitraName);
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ref.listen(pairingProvider, (prev, next) {
|
||||
if (next is PairingActiveData) {
|
||||
context.go('/chat/session/${next.sessionId}', extra: next.mitraName);
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
@@ -46,7 +46,6 @@ class BestieFoundScreen extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/chat/chat_bloc.dart';
|
||||
import '../../../core/chat/session_closure_bloc.dart';
|
||||
import '../../../core/chat/chat_notifier.dart';
|
||||
import '../../../core/chat/session_closure_notifier.dart';
|
||||
import '../../../core/constants.dart';
|
||||
import '../widgets/pricing_bottom_sheet.dart';
|
||||
|
||||
class ChatScreen extends StatefulWidget {
|
||||
class ChatScreen extends ConsumerStatefulWidget {
|
||||
final String sessionId;
|
||||
final String mitraName;
|
||||
|
||||
const ChatScreen({super.key, required this.sessionId, required this.mitraName});
|
||||
|
||||
@override
|
||||
State<ChatScreen> createState() => _ChatScreenState();
|
||||
ConsumerState<ChatScreen> createState() => _ChatScreenState();
|
||||
}
|
||||
|
||||
class _ChatScreenState extends State<ChatScreen> {
|
||||
class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
final _messageController = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
Timer? _typingThrottle;
|
||||
@@ -25,12 +25,12 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
context.read<ChatBloc>().add(ConnectChat(widget.sessionId));
|
||||
ref.read(chatProvider.notifier).connect(widget.sessionId);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
context.read<ChatBloc>().add(DisconnectChat());
|
||||
ref.read(chatProvider.notifier).disconnect();
|
||||
_messageController.dispose();
|
||||
_scrollController.dispose();
|
||||
_typingThrottle?.cancel();
|
||||
@@ -51,122 +51,100 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
|
||||
void _onTextChanged(String text) {
|
||||
if (_typingThrottle?.isActive ?? false) return;
|
||||
context.read<ChatBloc>().add(SendTyping());
|
||||
ref.read(chatProvider.notifier).sendTyping();
|
||||
_typingThrottle = Timer(const Duration(seconds: 2), () {});
|
||||
}
|
||||
|
||||
void _sendMessage() {
|
||||
final text = _messageController.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
context.read<ChatBloc>().add(SendMessage(text));
|
||||
ref.read(chatProvider.notifier).sendMessage(text);
|
||||
_messageController.clear();
|
||||
_scrollToBottom();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocListener(
|
||||
listeners: [
|
||||
BlocListener<ChatBloc, ChatState>(
|
||||
listenWhen: (prev, curr) {
|
||||
if (prev is ChatConnected && curr is ChatConnected) {
|
||||
return prev.sessionExpired != curr.sessionExpired ||
|
||||
prev.sessionClosing != curr.sessionClosing ||
|
||||
prev.sessionPaused != curr.sessionPaused ||
|
||||
prev.messages.length != curr.messages.length;
|
||||
final chatState = ref.watch(chatProvider);
|
||||
final closureState = ref.watch(sessionClosureProvider);
|
||||
|
||||
// Listen for closure complete to navigate home
|
||||
ref.listen(sessionClosureProvider, (prev, next) {
|
||||
if (next is ClosureCompleteData) {
|
||||
context.go('/home');
|
||||
}
|
||||
return true;
|
||||
},
|
||||
listener: (context, state) {
|
||||
if (state is ChatConnected) {
|
||||
// Only trigger goodbye if closing AND not expired (expired shows extend dialog first)
|
||||
if (state.sessionClosing && !state.sessionExpired) {
|
||||
final closureState = context.read<SessionClosureBloc>().state;
|
||||
if (closureState is ClosureInitial) {
|
||||
context.read<SessionClosureBloc>().add(DeclineExtension());
|
||||
});
|
||||
|
||||
// Listen for chat state changes to manage closure state
|
||||
ref.listen(chatProvider, (prev, next) {
|
||||
if (next is ChatConnectedData) {
|
||||
if (next.sessionClosing && !next.sessionExpired) {
|
||||
final closure = ref.read(sessionClosureProvider);
|
||||
if (closure is ClosureInitialData) {
|
||||
ref.read(sessionClosureProvider.notifier).declineExtension();
|
||||
}
|
||||
}
|
||||
// Extension accepted — reset closure bloc to go back to chat
|
||||
if (!state.sessionPaused && !state.sessionExpired && !state.sessionClosing) {
|
||||
final closureState = context.read<SessionClosureBloc>().state;
|
||||
if (closureState is! ClosureInitial) {
|
||||
context.read<SessionClosureBloc>().add(ResetClosure());
|
||||
if (!next.sessionPaused && !next.sessionExpired && !next.sessionClosing) {
|
||||
final closure = ref.read(sessionClosureProvider);
|
||||
if (closure is! ClosureInitialData) {
|
||||
ref.read(sessionClosureProvider.notifier).reset();
|
||||
}
|
||||
}
|
||||
_scrollToBottom();
|
||||
// Auto-mark received messages as read
|
||||
final unread = state.messages
|
||||
final unread = next.messages
|
||||
.where((m) => m.senderType == UserType.mitra && m.status != MessageStatus.read)
|
||||
.map((m) => m.id)
|
||||
.toList();
|
||||
if (unread.isNotEmpty) {
|
||||
context.read<ChatBloc>().add(MarkMessagesRead(unread));
|
||||
ref.read(chatProvider.notifier).markRead(unread);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
BlocListener<SessionClosureBloc, SessionClosureState>(
|
||||
listener: (context, state) {
|
||||
if (state is ClosureComplete) {
|
||||
context.go('/home');
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
child: Scaffold(
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.mitraName),
|
||||
automaticallyImplyLeading: false,
|
||||
actions: [
|
||||
BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (context, state) {
|
||||
if (state is ChatConnected && state.remainingSeconds != null) {
|
||||
return Padding(
|
||||
if (chatState is ChatConnectedData && chatState.remainingSeconds != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${state.remainingSeconds}s',
|
||||
'${chatState.remainingSeconds}s',
|
||||
style: TextStyle(
|
||||
color: state.remainingSeconds! < 30 ? Colors.red : null,
|
||||
color: chatState.remainingSeconds! < 30 ? Colors.red : null,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: BlocBuilder<ChatBloc, ChatState>(
|
||||
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, closureState),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChatBody(BuildContext context, ChatConnected state) {
|
||||
// Show goodbye input (takes priority — user already decided to close)
|
||||
final closureState = context.watch<SessionClosureBloc>().state;
|
||||
if (closureState is ClosureShowGoodbye || closureState is ClosureSubmitting) {
|
||||
return _buildGoodbyeView(context, closureState);
|
||||
Widget _buildBody(ChatData chatState, SessionClosureData closureState) {
|
||||
if (chatState is ChatConnectingData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (chatState is ChatErrorData) {
|
||||
return Center(child: Text(chatState.message));
|
||||
}
|
||||
if (chatState is ChatConnectedData) {
|
||||
return _buildChatBody(chatState, closureState);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
Widget _buildChatBody(ChatConnectedData state, SessionClosureData closureState) {
|
||||
if (closureState is ClosureShowGoodbyeData || closureState is ClosureSubmittingData) {
|
||||
return _buildGoodbyeView(closureState);
|
||||
}
|
||||
|
||||
// Show session expired dialog (extend or close?)
|
||||
if (state.sessionExpired) {
|
||||
return _buildExpiredView(context);
|
||||
return _buildExpiredView();
|
||||
}
|
||||
|
||||
if (state.sessionPaused) {
|
||||
@@ -195,7 +173,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
child: Text('Bestie sedang mengetik...', style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
),
|
||||
),
|
||||
_buildInputBar(context, state),
|
||||
_buildInputBar(),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -250,7 +228,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildInputBar(BuildContext context, ChatConnected state) {
|
||||
Widget _buildInputBar() {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
@@ -280,7 +258,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExpiredView(BuildContext context) {
|
||||
Widget _buildExpiredView() {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
@@ -289,9 +267,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
duration: const Duration(seconds: 300),
|
||||
builder: (context, remaining, _) {
|
||||
if (remaining <= 0) {
|
||||
// Auto-decline when countdown reaches 0
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<SessionClosureBloc>().add(DeclineExtension());
|
||||
ref.read(sessionClosureProvider.notifier).declineExtension();
|
||||
});
|
||||
}
|
||||
final minutes = remaining ~/ 60;
|
||||
@@ -320,7 +297,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(
|
||||
onPressed: () => context.read<SessionClosureBloc>().add(DeclineExtension()),
|
||||
onPressed: () => ref.read(sessionClosureProvider.notifier).declineExtension(),
|
||||
child: const Text('Tidak, akhiri sesi'),
|
||||
),
|
||||
],
|
||||
@@ -331,7 +308,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGoodbyeView(BuildContext context, SessionClosureState closureState) {
|
||||
Widget _buildGoodbyeView(SessionClosureData closureState) {
|
||||
final controller = TextEditingController();
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(32),
|
||||
@@ -354,17 +331,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: closureState is ClosureSubmitting
|
||||
onPressed: closureState is ClosureSubmittingData
|
||||
? null
|
||||
: () {
|
||||
final text = controller.text.trim();
|
||||
if (text.isNotEmpty) {
|
||||
context.read<SessionClosureBloc>().add(
|
||||
SubmitGoodbye(sessionId: widget.sessionId, message: text),
|
||||
ref.read(sessionClosureProvider.notifier).submitGoodbye(
|
||||
widget.sessionId, text,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: closureState is ClosureSubmitting
|
||||
child: closureState is ClosureSubmittingData
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('Kirim & Selesai'),
|
||||
),
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/pairing/pairing_bloc.dart';
|
||||
import '../../../core/pairing/pairing_notifier.dart';
|
||||
|
||||
class SearchingScreen extends StatelessWidget {
|
||||
class SearchingScreen extends ConsumerWidget {
|
||||
const SearchingScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<PairingBloc, PairingState>(
|
||||
listener: (context, state) {
|
||||
if (state is PairingBestieFound) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ref.listen(pairingProvider, (prev, next) {
|
||||
if (next is PairingBestieFoundData) {
|
||||
context.go('/chat/found', extra: {
|
||||
'sessionId': state.sessionId,
|
||||
'mitraName': state.mitraName,
|
||||
'sessionId': next.sessionId,
|
||||
'mitraName': next.mitraName,
|
||||
});
|
||||
} else if (state is PairingNoBestie) {
|
||||
} else if (next is PairingNoBestieData) {
|
||||
context.go('/chat/no-bestie');
|
||||
} else if (state is PairingCancelled) {
|
||||
} else if (next is PairingCancelledData) {
|
||||
context.go('/home');
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
@@ -42,14 +42,13 @@ class SearchingScreen extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
OutlinedButton(
|
||||
onPressed: () => context.read<PairingBloc>().add(CancelPairing()),
|
||||
onPressed: () => ref.read(pairingProvider.notifier).cancelPairing(),
|
||||
child: const Text('Batalkan'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/chat/chat_opening_provider.dart';
|
||||
import '../../../core/chat/session_closure_bloc.dart';
|
||||
import '../../../core/pairing/pairing_bloc.dart';
|
||||
import '../../../core/chat/session_closure_notifier.dart';
|
||||
import '../../../core/pairing/pairing_notifier.dart';
|
||||
|
||||
class PricingBottomSheet extends ConsumerWidget {
|
||||
/// If set, the bottom sheet is in "extension" mode — selecting a tier extends the session.
|
||||
@@ -16,12 +15,7 @@ class PricingBottomSheet extends ConsumerWidget {
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: context.read<PairingBloc>()),
|
||||
],
|
||||
child: const PricingBottomSheet(),
|
||||
),
|
||||
builder: (_) => const PricingBottomSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,12 +24,7 @@ class PricingBottomSheet extends ConsumerWidget {
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: context.read<SessionClosureBloc>()),
|
||||
],
|
||||
child: PricingBottomSheet(extensionSessionId: sessionId),
|
||||
),
|
||||
builder: (_) => PricingBottomSheet(extensionSessionId: sessionId),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,7 +79,7 @@ class PricingBottomSheet extends ConsumerWidget {
|
||||
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
_startPairing(context, isFreeTrial: true);
|
||||
_startPairing(ref, isFreeTrial: true);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -107,14 +96,14 @@ class PricingBottomSheet extends ConsumerWidget {
|
||||
Navigator.of(context).pop();
|
||||
if (isExtension) {
|
||||
_requestExtension(
|
||||
context,
|
||||
ref,
|
||||
sessionId: extensionSessionId!,
|
||||
durationMinutes: tier.durationMinutes,
|
||||
price: tier.price,
|
||||
);
|
||||
} else {
|
||||
_startPairing(
|
||||
context,
|
||||
ref,
|
||||
durationMinutes: tier.durationMinutes,
|
||||
price: tier.price,
|
||||
);
|
||||
@@ -130,19 +119,19 @@ class PricingBottomSheet extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _startPairing(BuildContext context, {bool isFreeTrial = false, int? durationMinutes, int? price}) {
|
||||
context.read<PairingBloc>().add(RequestPairingWithTier(
|
||||
void _startPairing(WidgetRef ref, {bool isFreeTrial = false, int? durationMinutes, int? price}) {
|
||||
ref.read(pairingProvider.notifier).requestPairingWithTier(
|
||||
durationMinutes: durationMinutes,
|
||||
price: price,
|
||||
isFreeTrial: isFreeTrial,
|
||||
));
|
||||
);
|
||||
}
|
||||
|
||||
void _requestExtension(BuildContext context, {required String sessionId, required int durationMinutes, required int price}) {
|
||||
context.read<SessionClosureBloc>().add(RequestExtension(
|
||||
sessionId: sessionId,
|
||||
void _requestExtension(WidgetRef ref, {required String sessionId, required int durationMinutes, required int price}) {
|
||||
ref.read(sessionClosureProvider.notifier).requestExtension(
|
||||
sessionId,
|
||||
durationMinutes: durationMinutes,
|
||||
price: price,
|
||||
));
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/auth/auth_notifier.dart';
|
||||
import '../../core/api/api_client_provider.dart';
|
||||
import '../../core/pairing/pairing_bloc.dart';
|
||||
import '../../core/pairing/pairing_notifier.dart';
|
||||
import '../chat/widgets/pricing_bottom_sheet.dart';
|
||||
|
||||
class HomeScreen extends ConsumerStatefulWidget {
|
||||
@@ -71,19 +70,19 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
|
||||
_ => '',
|
||||
};
|
||||
|
||||
return BlocListener<PairingBloc, PairingState>(
|
||||
listener: (context, state) {
|
||||
if (state is PairingSearching) {
|
||||
ref.listen(pairingProvider, (prev, next) {
|
||||
if (next is PairingSearchingData) {
|
||||
context.go('/chat/searching');
|
||||
} else if (state is PairingNoBestie) {
|
||||
} else if (next is PairingNoBestieData) {
|
||||
context.go('/chat/no-bestie');
|
||||
} else if (state is PairingError) {
|
||||
} else if (next is PairingErrorData) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(state.message)),
|
||||
SnackBar(content: Text(next.message)),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Halo Bestie'),
|
||||
actions: [
|
||||
@@ -130,7 +129,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'core/api/api_client_provider.dart';
|
||||
import 'core/auth/auth_notifier.dart';
|
||||
import 'core/chat/chat_bloc.dart';
|
||||
import 'core/chat/session_closure_bloc.dart';
|
||||
import 'core/pairing/pairing_bloc.dart';
|
||||
import 'core/notifications/notification_service.dart';
|
||||
import 'firebase_options.dart';
|
||||
import 'router.dart';
|
||||
@@ -49,7 +45,6 @@ class _AppState extends ConsumerState<App> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Listen for auth changes to register FCM token
|
||||
ref.listen(authProvider, (prev, next) {
|
||||
final data = next.valueOrNull;
|
||||
if (data is AuthAuthenticatedData || data is AuthAnonymousData) {
|
||||
@@ -58,23 +53,12 @@ class _AppState extends ConsumerState<App> {
|
||||
});
|
||||
|
||||
final router = ref.watch(routerProvider);
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
|
||||
// Initialize notifications once router is available
|
||||
NotificationService.initialize(router);
|
||||
|
||||
// Keep BlocProviders for non-migrated blocs (will be removed as they're migrated)
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(create: (_) => PairingBloc(apiClient: apiClient)),
|
||||
BlocProvider(create: (_) => ChatBloc(apiClient: apiClient)),
|
||||
BlocProvider(create: (_) => SessionClosureBloc(apiClient: apiClient)),
|
||||
RepositoryProvider.value(value: apiClient),
|
||||
],
|
||||
child: MaterialApp.router(
|
||||
return MaterialApp.router(
|
||||
title: 'Halo Bestie',
|
||||
routerConfig: router,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user