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;
|
||||
|
||||
173
client_app/lib/core/pairing/pairing_bloc.dart
Normal file
173
client_app/lib/core/pairing/pairing_bloc.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
39
client_app/lib/features/chat/screens/no_bestie_screen.dart
Normal file
39
client_app/lib/features/chat/screens/no_bestie_screen.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
55
client_app/lib/features/chat/screens/searching_screen.dart
Normal file
55
client_app/lib/features/chat/screens/searching_screen.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user