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

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