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;

View File

@@ -0,0 +1,173 @@
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 PairingEvent extends Equatable {
@override
List<Object?> get props => [];
}
class RequestPairing extends PairingEvent {}
class CancelPairing extends PairingEvent {}
class _PairingStatusUpdate extends PairingEvent {
final Map<String, dynamic> data;
_PairingStatusUpdate(this.data);
@override
List<Object?> get props => [data];
}
class _PairingTimeout extends PairingEvent {}
// States
abstract class PairingState extends Equatable {
@override
List<Object?> get props => [];
}
class PairingInitial extends PairingState {}
class PairingSearching extends PairingState {
final String sessionId;
PairingSearching(this.sessionId);
@override
List<Object?> get props => [sessionId];
}
class PairingBestieFound extends PairingState {
final String sessionId;
final String mitraName;
PairingBestieFound({required this.sessionId, required this.mitraName});
@override
List<Object?> get props => [sessionId, mitraName];
}
class PairingActive extends PairingState {
final String sessionId;
final String mitraName;
PairingActive({required this.sessionId, required this.mitraName});
@override
List<Object?> get props => [sessionId, mitraName];
}
class PairingNoBestie extends PairingState {}
class PairingCancelled extends PairingState {}
class PairingError extends PairingState {
final String message;
PairingError(this.message);
@override
List<Object?> get props => [message];
}
// Bloc
class PairingBloc extends Bloc<PairingEvent, PairingState> {
final ApiClient apiClient;
Timer? _timeoutTimer;
StreamSubscription? _sseSubscription;
PairingBloc({required this.apiClient}) : super(PairingInitial()) {
on<RequestPairing>(_onRequestPairing);
on<CancelPairing>(_onCancelPairing);
on<_PairingStatusUpdate>(_onStatusUpdate);
on<_PairingTimeout>(_onTimeout);
}
Future<void> _onRequestPairing(RequestPairing event, Emitter<PairingState> emit) async {
try {
final response = await apiClient.post('/api/client/chat/request');
final data = response['data'] as Map<String, dynamic>;
final sessionId = data['id'] as String;
emit(PairingSearching(sessionId));
// Start 60s local timeout as a safety net
_timeoutTimer = Timer(const Duration(seconds: 60), () {
add(_PairingTimeout());
});
// Listen to SSE for status updates
_listenToSSE(sessionId);
} on DioException catch (e) {
final code = e.response?.data?['error']?['code'];
if (code == 'NO_MITRA_AVAILABLE') {
emit(PairingNoBestie());
} else if (code == 'ALREADY_ACTIVE') {
emit(PairingError('Kamu sudah memiliki sesi aktif.'));
} else {
emit(PairingError('Gagal memulai. Coba lagi.'));
}
}
}
void _listenToSSE(String sessionId) {
final dio = Dio(BaseOptions(baseUrl: ApiClient.baseUrl));
// SSE endpoint — use responseType stream
dio.get(
'/api/client/chat/request/$sessionId/status',
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(_PairingStatusUpdate(data)),
onError: (_) {},
);
}).catchError((_) {});
}
Future<void> _onStatusUpdate(_PairingStatusUpdate event, Emitter<PairingState> emit) async {
final data = event.data;
final type = data['type'] as String?;
if (type == 'paired') {
_cleanup();
final mitraName = data['mitra_display_name'] as String? ?? 'Bestie';
final sessionId = data['session_id'] as String;
emit(PairingBestieFound(sessionId: sessionId, mitraName: mitraName));
// Brief delay then transition to active
await Future.delayed(const Duration(seconds: 2));
emit(PairingActive(sessionId: sessionId, mitraName: mitraName));
} else if (type == 'expired') {
_cleanup();
emit(PairingNoBestie());
}
}
Future<void> _onCancelPairing(CancelPairing event, Emitter<PairingState> emit) async {
if (state is PairingSearching) {
final sessionId = (state as PairingSearching).sessionId;
try {
await apiClient.post('/api/client/chat/request/$sessionId/cancel');
} catch (_) {}
_cleanup();
emit(PairingCancelled());
}
}
Future<void> _onTimeout(_PairingTimeout event, Emitter<PairingState> emit) async {
_cleanup();
emit(PairingNoBestie());
}
void _cleanup() {
_timeoutTimer?.cancel();
_timeoutTimer = null;
_sseSubscription?.cancel();
_sseSubscription = null;
}
@override
Future<void> close() {
_cleanup();
return super.close();
}
}

View File

@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../core/pairing/pairing_bloc.dart';
class BestieFoundScreen extends StatelessWidget {
final String sessionId;
final String mitraName;
const BestieFoundScreen({
super.key,
required this.sessionId,
required this.mitraName,
});
@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);
}
},
child: Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.check_circle, size: 80, color: Colors.green),
const SizedBox(height: 24),
const Text(
'Bestie ditemukan!',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'Menghubungkan kamu ke $mitraName',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16, color: Colors.grey),
),
const SizedBox(height: 24),
const CircularProgressIndicator(),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class NoBestieScreen extends StatelessWidget {
const NoBestieScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.sentiment_dissatisfied, size: 80, color: Colors.orange),
const SizedBox(height: 24),
const Text(
'Bestie belum tersedia',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Maaf, semua Bestie sedang sibuk. Coba lagi nanti ya.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, color: Colors.grey),
),
const SizedBox(height: 48),
ElevatedButton(
onPressed: () => context.go('/home'),
child: const Text('Kembali'),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../core/pairing/pairing_bloc.dart';
class SearchingScreen extends StatelessWidget {
const SearchingScreen({super.key});
@override
Widget build(BuildContext context) {
return BlocListener<PairingBloc, PairingState>(
listener: (context, state) {
if (state is PairingBestieFound) {
context.go('/chat/found', extra: {
'sessionId': state.sessionId,
'mitraName': state.mitraName,
});
} else if (state is PairingNoBestie) {
context.go('/chat/no-bestie');
} else if (state is PairingCancelled) {
context.go('/home');
}
},
child: Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 32),
const Text(
'Mencari Bestie...',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Tunggu sebentar ya, kami sedang mencarikan Bestie untukmu',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, color: Colors.grey),
),
const SizedBox(height: 48),
OutlinedButton(
onPressed: () => context.read<PairingBloc>().add(CancelPairing()),
child: const Text('Batalkan'),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_client.dart';
class SessionActiveScreen extends StatelessWidget {
final String sessionId;
final String mitraName;
const SessionActiveScreen({
super.key,
required this.sessionId,
required this.mitraName,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Sesi Aktif'),
automaticallyImplyLeading: false,
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.chat_bubble, size: 80, color: Colors.blue),
const SizedBox(height: 24),
Text(
'Terhubung dengan $mitraName',
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
const Text(
'Sesi chat akan tersedia di fase berikutnya.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.grey),
),
const SizedBox(height: 48),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
onPressed: () => _endSession(context),
child: const Text('Akhiri Sesi', style: TextStyle(color: Colors.white)),
),
],
),
),
),
);
}
Future<void> _endSession(BuildContext context) 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 && context.mounted) {
try {
final apiClient = context.read<ApiClient>();
await apiClient.post('/api/client/chat/session/$sessionId/end');
if (context.mounted) context.go('/home');
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Gagal mengakhiri sesi. Coba lagi.')),
);
}
}
}
}
}

View File

@@ -1,39 +1,66 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../core/auth/auth_bloc.dart';
import '../../core/pairing/pairing_bloc.dart';
/// Phase 1 placeholder — will be replaced in Phase 2 with chat/session 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
: state is AuthAnonymous
? state.displayName
: '';
return Scaffold(
appBar: AppBar(
title: const Text('Halo Bestie'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () => context.read<AuthBloc>().add(LogoutRequested()),
),
],
),
body: Center(
child: Text(
'Halo, $displayName!',
style: const TextStyle(fontSize: 24),
),
),
);
return BlocListener<PairingBloc, PairingState>(
listener: (context, state) {
if (state is PairingSearching) {
context.go('/chat/searching');
} else if (state is PairingNoBestie) {
context.go('/chat/no-bestie');
} else if (state is PairingError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message)),
);
}
},
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
final displayName = state is AuthAuthenticated
? state.profile['display_name'] as String
: state is AuthAnonymous
? state.displayName
: '';
return Scaffold(
appBar: AppBar(
title: const Text('Halo Bestie'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () => context.read<AuthBloc>().add(LogoutRequested()),
),
],
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)),
const SizedBox(height: 48),
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16),
),
onPressed: () => context.read<PairingBloc>().add(RequestPairing()),
child: const Text('Mulai Curhat', style: TextStyle(fontSize: 18)),
),
],
),
),
),
);
},
),
);
}
}

View File

@@ -3,6 +3,7 @@ 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/pairing/pairing_bloc.dart';
import 'firebase_options.dart';
import 'router.dart';
@@ -17,8 +18,13 @@ class App extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => AuthBloc(apiClient: ApiClient())..add(AppStarted()),
final apiClient = ApiClient();
return MultiBlocProvider(
providers: [
BlocProvider(create: (_) => AuthBloc(apiClient: apiClient)..add(AppStarted())),
BlocProvider(create: (_) => PairingBloc(apiClient: apiClient)),
RepositoryProvider.value(value: apiClient),
],
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
return MaterialApp.router(

View File

@@ -7,6 +7,10 @@ import 'features/auth/screens/register_screen.dart';
import 'features/auth/screens/otp_screen.dart';
import 'features/auth/screens/force_register_screen.dart';
import 'features/home/home_screen.dart';
import 'features/chat/screens/searching_screen.dart';
import 'features/chat/screens/bestie_found_screen.dart';
import 'features/chat/screens/no_bestie_screen.dart';
import 'features/chat/screens/session_active_screen.dart';
GoRouter buildRouter(AuthBloc authBloc) {
return GoRouter(
@@ -30,6 +34,21 @@ GoRouter buildRouter(AuthBloc authBloc) {
GoRoute(path: '/auth/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)),
GoRoute(path: '/auth/force-register', builder: (_, __) => const ForceRegisterScreen()),
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
GoRoute(path: '/chat/searching', builder: (_, __) => const SearchingScreen()),
GoRoute(path: '/chat/found', builder: (context, state) {
final extra = state.extra as Map<String, dynamic>;
return BestieFoundScreen(
sessionId: extra['sessionId'] as String,
mitraName: extra['mitraName'] as String,
);
}),
GoRoute(path: '/chat/no-bestie', builder: (_, __) => const NoBestieScreen()),
GoRoute(path: '/chat/session/:sessionId', builder: (context, state) {
return SessionActiveScreen(
sessionId: state.pathParameters['sessionId']!,
mitraName: state.extra as String? ?? 'Bestie',
);
}),
],
);
}