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:
2026-04-05 23:17:49 +08:00
parent a7a2a32d27
commit d668112edd
44 changed files with 2800 additions and 80 deletions

View File

@@ -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>;

View 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();
}
}

View 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();
}
}

View File

@@ -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)),
),
);
},
),
);
}
}

View File

@@ -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'),
),
),
],
),
],
),
);
}
}

View File

@@ -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'),
),
);
}
}

View File

@@ -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>()),
);
},
),
),
);
}

View File

@@ -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()),
],
);
}