Phase 2 scaffold: mitra online status & pairing logic
Add mitra online/offline status with heartbeat-based auto-offline, customer-mitra pairing via Valkey pub/sub blast, session management, and control center dashboard with real-time stats. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@ import 'package:dio/dio.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
|
||||
class ApiClient {
|
||||
static const String _baseUrl = String.fromEnvironment(
|
||||
static const String baseUrl = String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: 'https://api.halobestie.com',
|
||||
);
|
||||
@@ -10,7 +10,7 @@ class ApiClient {
|
||||
late final Dio _dio;
|
||||
|
||||
ApiClient() {
|
||||
_dio = Dio(BaseOptions(baseUrl: _baseUrl));
|
||||
_dio = Dio(BaseOptions(baseUrl: baseUrl));
|
||||
_dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) async {
|
||||
final user = FirebaseAuth.instance.currentUser;
|
||||
@@ -23,6 +23,11 @@ class ApiClient {
|
||||
));
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> get(String path, {Map<String, dynamic>? queryParameters}) async {
|
||||
final response = await _dio.get(path, queryParameters: queryParameters);
|
||||
return response.data as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> post(String path, {Map<String, dynamic>? data}) async {
|
||||
final response = await _dio.post(path, data: data);
|
||||
return response.data as Map<String, dynamic>;
|
||||
|
||||
165
mitra_app/lib/core/chat/chat_request_bloc.dart
Normal file
165
mitra_app/lib/core/chat/chat_request_bloc.dart
Normal file
@@ -0,0 +1,165 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../api/api_client.dart';
|
||||
|
||||
// Events
|
||||
abstract class ChatRequestEvent extends Equatable {
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class StartListening extends ChatRequestEvent {}
|
||||
class StopListening extends ChatRequestEvent {}
|
||||
|
||||
class _RequestReceived extends ChatRequestEvent {
|
||||
final Map<String, dynamic> data;
|
||||
_RequestReceived(this.data);
|
||||
@override
|
||||
List<Object?> get props => [data];
|
||||
}
|
||||
|
||||
class AcceptRequest extends ChatRequestEvent {
|
||||
final String sessionId;
|
||||
AcceptRequest(this.sessionId);
|
||||
@override
|
||||
List<Object?> get props => [sessionId];
|
||||
}
|
||||
|
||||
class DeclineRequest extends ChatRequestEvent {
|
||||
final String sessionId;
|
||||
DeclineRequest(this.sessionId);
|
||||
@override
|
||||
List<Object?> get props => [sessionId];
|
||||
}
|
||||
|
||||
// States
|
||||
abstract class ChatRequestState extends Equatable {
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class ChatRequestIdle extends ChatRequestState {}
|
||||
class ChatRequestListening extends ChatRequestState {}
|
||||
|
||||
class ChatRequestIncoming extends ChatRequestState {
|
||||
final String sessionId;
|
||||
ChatRequestIncoming(this.sessionId);
|
||||
@override
|
||||
List<Object?> get props => [sessionId];
|
||||
}
|
||||
|
||||
class ChatRequestAccepting extends ChatRequestState {}
|
||||
|
||||
class ChatRequestAccepted extends ChatRequestState {
|
||||
final Map<String, dynamic> session;
|
||||
ChatRequestAccepted(this.session);
|
||||
@override
|
||||
List<Object?> get props => [session];
|
||||
}
|
||||
|
||||
class ChatRequestError extends ChatRequestState {
|
||||
final String message;
|
||||
ChatRequestError(this.message);
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
// Bloc
|
||||
class ChatRequestBloc extends Bloc<ChatRequestEvent, ChatRequestState> {
|
||||
final ApiClient apiClient;
|
||||
StreamSubscription? _sseSubscription;
|
||||
|
||||
ChatRequestBloc({required this.apiClient}) : super(ChatRequestIdle()) {
|
||||
on<StartListening>(_onStartListening);
|
||||
on<StopListening>(_onStopListening);
|
||||
on<_RequestReceived>(_onRequestReceived);
|
||||
on<AcceptRequest>(_onAcceptRequest);
|
||||
on<DeclineRequest>(_onDeclineRequest);
|
||||
}
|
||||
|
||||
Future<void> _onStartListening(StartListening event, Emitter<ChatRequestState> emit) async {
|
||||
_stopSSE();
|
||||
emit(ChatRequestListening());
|
||||
_listenToSSE();
|
||||
}
|
||||
|
||||
Future<void> _onStopListening(StopListening event, Emitter<ChatRequestState> emit) async {
|
||||
_stopSSE();
|
||||
emit(ChatRequestIdle());
|
||||
}
|
||||
|
||||
void _listenToSSE() {
|
||||
final dio = Dio(BaseOptions(baseUrl: ApiClient.baseUrl));
|
||||
dio.get(
|
||||
'/api/mitra/chat-requests/incoming',
|
||||
options: Options(responseType: ResponseType.stream),
|
||||
).then((response) {
|
||||
final stream = response.data.stream as Stream<List<int>>;
|
||||
_sseSubscription = stream
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.where((line) => line.startsWith('data: '))
|
||||
.map((line) => jsonDecode(line.substring(6)) as Map<String, dynamic>)
|
||||
.listen(
|
||||
(data) => add(_RequestReceived(data)),
|
||||
onError: (_) {},
|
||||
);
|
||||
}).catchError((_) {});
|
||||
}
|
||||
|
||||
Future<void> _onRequestReceived(_RequestReceived event, Emitter<ChatRequestState> emit) async {
|
||||
final data = event.data;
|
||||
final type = data['type'] as String?;
|
||||
|
||||
if (type == 'chat_request') {
|
||||
emit(ChatRequestIncoming(data['session_id'] as String));
|
||||
} else if (type == 'chat_request_closed') {
|
||||
// Request was taken by another mitra or cancelled
|
||||
if (state is ChatRequestIncoming) {
|
||||
emit(ChatRequestListening());
|
||||
}
|
||||
} else if (type == 'session_rerouted') {
|
||||
// A session was rerouted away from us — refresh active sessions
|
||||
emit(ChatRequestListening());
|
||||
} else if (type == 'session_assigned') {
|
||||
// A session was force-assigned to us
|
||||
emit(ChatRequestAccepted({'session_id': data['session_id']}));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onAcceptRequest(AcceptRequest event, Emitter<ChatRequestState> emit) async {
|
||||
emit(ChatRequestAccepting());
|
||||
try {
|
||||
final response = await apiClient.post('/api/mitra/chat-requests/${event.sessionId}/accept');
|
||||
emit(ChatRequestAccepted(response['data'] as Map<String, dynamic>));
|
||||
} on DioException catch (e) {
|
||||
final code = e.response?.data?['error']?['code'];
|
||||
if (code == 'REQUEST_UNAVAILABLE') {
|
||||
emit(ChatRequestListening());
|
||||
} else {
|
||||
emit(ChatRequestError('Gagal menerima. Coba lagi.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onDeclineRequest(DeclineRequest event, Emitter<ChatRequestState> emit) async {
|
||||
try {
|
||||
await apiClient.post('/api/mitra/chat-requests/${event.sessionId}/decline');
|
||||
} catch (_) {}
|
||||
emit(ChatRequestListening());
|
||||
}
|
||||
|
||||
void _stopSSE() {
|
||||
_sseSubscription?.cancel();
|
||||
_sseSubscription = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_stopSSE();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
129
mitra_app/lib/core/status/status_bloc.dart
Normal file
129
mitra_app/lib/core/status/status_bloc.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
import 'dart:async';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../api/api_client.dart';
|
||||
|
||||
// Events
|
||||
abstract class StatusEvent extends Equatable {
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class StatusLoadRequested extends StatusEvent {}
|
||||
class ToggleOnline extends StatusEvent {}
|
||||
class ToggleOffline extends StatusEvent {}
|
||||
class HeartbeatTick extends StatusEvent {}
|
||||
class AppPaused extends StatusEvent {}
|
||||
class AppResumed extends StatusEvent {}
|
||||
|
||||
// States
|
||||
abstract class StatusState extends Equatable {
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class StatusInitial extends StatusState {}
|
||||
|
||||
class StatusLoaded extends StatusState {
|
||||
final bool isOnline;
|
||||
StatusLoaded({required this.isOnline});
|
||||
@override
|
||||
List<Object?> get props => [isOnline];
|
||||
}
|
||||
|
||||
class StatusLoading extends StatusState {}
|
||||
|
||||
class StatusError extends StatusState {
|
||||
final String message;
|
||||
StatusError(this.message);
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
// Bloc
|
||||
class StatusBloc extends Bloc<StatusEvent, StatusState> {
|
||||
final ApiClient apiClient;
|
||||
Timer? _heartbeatTimer;
|
||||
|
||||
StatusBloc({required this.apiClient}) : super(StatusInitial()) {
|
||||
on<StatusLoadRequested>(_onLoad);
|
||||
on<ToggleOnline>(_onToggleOnline);
|
||||
on<ToggleOffline>(_onToggleOffline);
|
||||
on<HeartbeatTick>(_onHeartbeat);
|
||||
on<AppPaused>(_onAppPaused);
|
||||
on<AppResumed>(_onAppResumed);
|
||||
}
|
||||
|
||||
Future<void> _onLoad(StatusLoadRequested event, Emitter<StatusState> emit) async {
|
||||
try {
|
||||
final response = await apiClient.get('/api/mitra/status');
|
||||
final data = response['data'] as Map<String, dynamic>;
|
||||
emit(StatusLoaded(isOnline: data['is_online'] as bool));
|
||||
} catch (e) {
|
||||
emit(StatusLoaded(isOnline: false));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onToggleOnline(ToggleOnline event, Emitter<StatusState> emit) async {
|
||||
emit(StatusLoading());
|
||||
try {
|
||||
await apiClient.post('/api/mitra/status/online');
|
||||
_startHeartbeat();
|
||||
emit(StatusLoaded(isOnline: true));
|
||||
} catch (e) {
|
||||
emit(StatusError('Gagal mengubah status. Coba lagi.'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onToggleOffline(ToggleOffline event, Emitter<StatusState> emit) async {
|
||||
emit(StatusLoading());
|
||||
try {
|
||||
await apiClient.post('/api/mitra/status/offline');
|
||||
_stopHeartbeat();
|
||||
emit(StatusLoaded(isOnline: false));
|
||||
} catch (e) {
|
||||
emit(StatusError('Gagal mengubah status. Coba lagi.'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onHeartbeat(HeartbeatTick event, Emitter<StatusState> emit) async {
|
||||
try {
|
||||
await apiClient.post('/api/mitra/status/heartbeat');
|
||||
} catch (_) {
|
||||
// Heartbeat failure is non-critical; server will auto-offline after 45s
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onAppPaused(AppPaused event, Emitter<StatusState> emit) async {
|
||||
if (state is StatusLoaded && (state as StatusLoaded).isOnline) {
|
||||
try {
|
||||
await apiClient.post('/api/mitra/status/offline');
|
||||
} catch (_) {}
|
||||
_stopHeartbeat();
|
||||
emit(StatusLoaded(isOnline: false));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onAppResumed(AppResumed event, Emitter<StatusState> emit) async {
|
||||
// Do NOT auto-set online on resume; mitra must explicitly toggle
|
||||
add(StatusLoadRequested());
|
||||
}
|
||||
|
||||
void _startHeartbeat() {
|
||||
_stopHeartbeat();
|
||||
_heartbeatTimer = Timer.periodic(const Duration(seconds: 15), (_) {
|
||||
add(HeartbeatTick());
|
||||
});
|
||||
}
|
||||
|
||||
void _stopHeartbeat() {
|
||||
_heartbeatTimer?.cancel();
|
||||
_heartbeatTimer = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_stopHeartbeat();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/api/api_client.dart';
|
||||
|
||||
class ActiveSessionsScreen extends StatefulWidget {
|
||||
const ActiveSessionsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ActiveSessionsScreen> createState() => _ActiveSessionsScreenState();
|
||||
}
|
||||
|
||||
class _ActiveSessionsScreenState extends State<ActiveSessionsScreen> {
|
||||
List<Map<String, dynamic>> _sessions = [];
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSessions();
|
||||
}
|
||||
|
||||
Future<void> _loadSessions() async {
|
||||
try {
|
||||
final apiClient = context.read<ApiClient>();
|
||||
final response = await apiClient.get('/api/mitra/chat-requests/sessions/active');
|
||||
setState(() {
|
||||
_sessions = List<Map<String, dynamic>>.from(response['data'] as List);
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _endSession(String sessionId) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Akhiri Sesi?'),
|
||||
content: const Text('Apakah kamu yakin ingin mengakhiri sesi ini?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Batal')),
|
||||
TextButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Ya, Akhiri')),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
try {
|
||||
final apiClient = context.read<ApiClient>();
|
||||
await apiClient.post('/api/mitra/chat-requests/sessions/$sessionId/end');
|
||||
_loadSessions();
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Gagal mengakhiri sesi.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Sesi Aktif')),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _sessions.isEmpty
|
||||
? const Center(child: Text('Tidak ada sesi aktif.'))
|
||||
: ListView.builder(
|
||||
itemCount: _sessions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final session = _sessions[index];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.person),
|
||||
title: Text(session['customer_display_name'] as String? ?? 'Customer'),
|
||||
subtitle: Text('Status: ${session['status']}'),
|
||||
trailing: TextButton(
|
||||
onPressed: () => _endSession(session['id'] as String),
|
||||
child: const Text('Akhiri', style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/chat/chat_request_bloc.dart';
|
||||
|
||||
class IncomingRequestSheet extends StatelessWidget {
|
||||
final String sessionId;
|
||||
const IncomingRequestSheet({super.key, required this.sessionId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.chat, size: 48, color: Colors.blue),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Ada permintaan chat baru!',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Seorang customer ingin curhat denganmu.',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
context.read<ChatRequestBloc>().add(DeclineRequest(sessionId));
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Tolak'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
context.read<ChatRequestBloc>().add(AcceptRequest(sessionId));
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Terima'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,150 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../core/auth/auth_bloc.dart';
|
||||
import '../../core/status/status_bloc.dart';
|
||||
import '../../core/chat/chat_request_bloc.dart';
|
||||
import '../chat/widgets/incoming_request_sheet.dart';
|
||||
|
||||
/// Phase 1 placeholder — will be replaced in Phase 2 with session/chat features.
|
||||
class HomeScreen extends StatelessWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
final displayName = state is AuthAuthenticated
|
||||
? state.profile['display_name'] as String
|
||||
: '';
|
||||
return MultiBlocListener(
|
||||
listeners: [
|
||||
BlocListener<StatusBloc, StatusState>(
|
||||
listener: (context, state) {
|
||||
if (state is StatusLoaded && state.isOnline) {
|
||||
context.read<ChatRequestBloc>().add(StartListening());
|
||||
} else if (state is StatusLoaded && !state.isOnline) {
|
||||
context.read<ChatRequestBloc>().add(StopListening());
|
||||
}
|
||||
},
|
||||
),
|
||||
BlocListener<ChatRequestBloc, ChatRequestState>(
|
||||
listener: (context, state) {
|
||||
if (state is ChatRequestIncoming) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isDismissible: false,
|
||||
builder: (_) => BlocProvider.value(
|
||||
value: context.read<ChatRequestBloc>(),
|
||||
child: IncomingRequestSheet(sessionId: state.sessionId),
|
||||
),
|
||||
);
|
||||
} else if (state is ChatRequestAccepted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Sesi baru diterima!')),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
child: BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, authState) {
|
||||
final displayName = authState is AuthAuthenticated
|
||||
? authState.profile['display_name'] as String
|
||||
: '';
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Halo Bestie Mitra'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout),
|
||||
onPressed: () => context.read<AuthBloc>().add(LogoutRequested()),
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Halo Bestie Mitra'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout),
|
||||
onPressed: () => context.read<AuthBloc>().add(LogoutRequested()),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)),
|
||||
const SizedBox(height: 32),
|
||||
_StatusToggle(),
|
||||
const SizedBox(height: 16),
|
||||
_ActiveSessionsButton(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Center(
|
||||
child: Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusToggle extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<StatusBloc, StatusState>(
|
||||
builder: (context, state) {
|
||||
final isOnline = state is StatusLoaded && state.isOnline;
|
||||
final isLoading = state is StatusLoading;
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isOnline ? 'Online' : 'Offline',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isOnline ? Colors.green : Colors.grey,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
isOnline
|
||||
? 'Kamu siap menerima chat'
|
||||
: 'Aktifkan untuk menerima chat',
|
||||
style: const TextStyle(fontSize: 14, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
isLoading
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Switch(
|
||||
value: isOnline,
|
||||
activeColor: Colors.green,
|
||||
onChanged: (_) {
|
||||
final bloc = context.read<StatusBloc>();
|
||||
if (isOnline) {
|
||||
bloc.add(ToggleOffline());
|
||||
} else {
|
||||
bloc.add(ToggleOnline());
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActiveSessionsButton extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.chat_bubble_outline),
|
||||
title: const Text('Sesi Aktif'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.of(context).pushNamed('/sessions'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'core/api/api_client.dart';
|
||||
import 'core/auth/auth_bloc.dart';
|
||||
import 'core/status/status_bloc.dart';
|
||||
import 'core/chat/chat_request_bloc.dart';
|
||||
import 'firebase_options.dart';
|
||||
import 'router.dart';
|
||||
|
||||
@@ -12,20 +14,67 @@ void main() async {
|
||||
runApp(const App());
|
||||
}
|
||||
|
||||
class App extends StatelessWidget {
|
||||
class App extends StatefulWidget {
|
||||
const App({super.key});
|
||||
|
||||
@override
|
||||
State<App> createState() => _AppState();
|
||||
}
|
||||
|
||||
class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
late final ApiClient _apiClient;
|
||||
late final StatusBloc _statusBloc;
|
||||
late final ChatRequestBloc _chatRequestBloc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_apiClient = ApiClient();
|
||||
_statusBloc = StatusBloc(apiClient: _apiClient);
|
||||
_chatRequestBloc = ChatRequestBloc(apiClient: _apiClient);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_statusBloc.close();
|
||||
_chatRequestBloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.paused || state == AppLifecycleState.detached) {
|
||||
_statusBloc.add(AppPaused());
|
||||
} else if (state == AppLifecycleState.resumed) {
|
||||
_statusBloc.add(AppResumed());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) => AuthBloc(apiClient: ApiClient())..add(AppStarted()),
|
||||
child: BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
return MaterialApp.router(
|
||||
title: 'Halo Bestie Mitra',
|
||||
routerConfig: buildRouter(context.read<AuthBloc>()),
|
||||
);
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(create: (_) => AuthBloc(apiClient: _apiClient)..add(AppStarted())),
|
||||
BlocProvider.value(value: _statusBloc),
|
||||
BlocProvider.value(value: _chatRequestBloc),
|
||||
RepositoryProvider.value(value: _apiClient),
|
||||
],
|
||||
child: BlocListener<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
if (state is AuthAuthenticated) {
|
||||
_statusBloc.add(StatusLoadRequested());
|
||||
}
|
||||
},
|
||||
child: BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
return MaterialApp.router(
|
||||
title: 'Halo Bestie Mitra',
|
||||
routerConfig: buildRouter(context.read<AuthBloc>()),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'core/auth/auth_bloc.dart';
|
||||
import 'features/auth/screens/login_screen.dart';
|
||||
import 'features/auth/screens/otp_screen.dart';
|
||||
import 'features/home/home_screen.dart';
|
||||
import 'features/chat/screens/active_sessions_screen.dart';
|
||||
|
||||
GoRouter buildRouter(AuthBloc authBloc) {
|
||||
return GoRouter(
|
||||
@@ -20,6 +21,7 @@ GoRouter buildRouter(AuthBloc authBloc) {
|
||||
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
|
||||
GoRoute(path: '/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)),
|
||||
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
|
||||
GoRoute(path: '/sessions', builder: (_, __) => const ActiveSessionsScreen()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user