csn_flutter 1.0.1
csn_flutter: ^1.0.1 copied to clipboard
Flutter SDK for CSN realtime audio/video calling with prebuilt request-queue and call UI.
example/lib/main.dart
import 'dart:async';
import 'dart:convert';
import 'package:csn_flutter/csn_flutter.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:shared_preferences/shared_preferences.dart';
const String _appName = 'Deafott Customer Support';
enum _StaffRole {
admin,
executive,
}
const String _sessionTokenKey = 'csn_session_token';
const String _sessionRoleKey = 'csn_session_role';
const String _sessionUserIdKey = 'csn_session_user_id';
const String _sessionUserNameKey = 'csn_session_user_name';
const String _sessionUserEmailKey = 'csn_session_user_email';
String _formatDurationSeconds(int? seconds) {
final total = seconds ?? 0;
final minutes = total ~/ 60;
final remainingSeconds = total % 60;
if (minutes <= 0) return '${remainingSeconds}s';
return '${minutes}m ${remainingSeconds.toString().padLeft(2, '0')}s';
}
String _formatExactTime(String? isoValue) {
if (isoValue == null || isoValue.trim().isEmpty) return '-';
final parsed = DateTime.tryParse(isoValue);
if (parsed == null) return isoValue;
final local = parsed.toLocal();
final date =
'${local.year}-${local.month.toString().padLeft(2, '0')}-${local.day.toString().padLeft(2, '0')}';
final time =
'${local.hour.toString().padLeft(2, '0')}:${local.minute.toString().padLeft(2, '0')}:${local.second.toString().padLeft(2, '0')}';
return '$date $time';
}
String _formatPeriod(String isoValue, {required bool monthOnly}) {
final parsed = DateTime.tryParse(isoValue)?.toLocal();
if (parsed == null) return isoValue;
final month = parsed.month.toString().padLeft(2, '0');
if (monthOnly) return '${parsed.year}-$month';
final day = parsed.day.toString().padLeft(2, '0');
return '${parsed.year}-$month-$day';
}
String _requestTypeLabel(CsnSupportRequestType? requestType) {
return requestType == CsnSupportRequestType.liveChat
? 'Live Chat'
: 'Video Call';
}
String _customerName(String? userName, String userId) {
final cleanName = userName?.trim();
if (cleanName != null && cleanName.isNotEmpty) return cleanName;
return userId;
}
String _customerSubtitle(String? userEmail, String userId) {
final cleanEmail = userEmail?.trim();
if (cleanEmail != null && cleanEmail.isNotEmpty) return cleanEmail;
return 'ID: $userId';
}
int _statsTotalForPeriod(
List<StaffStatsItem> items, {
required String period,
required CsnSupportRequestType requestType,
}) {
return items
.where((item) =>
_formatPeriod(item.periodStart, monthOnly: false) == period &&
item.requestType == requestType)
.fold<int>(0, (total, item) => total + item.total);
}
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
WidgetsFlutterBinding.ensureInitialized();
try {
await Firebase.initializeApp();
} catch (_) {
// no-op
}
final plugin = FlutterLocalNotificationsPlugin();
const settings = InitializationSettings(
android: AndroidInitializationSettings(
'@drawable/ic_admin_support_notification'),
);
await plugin.initialize(settings);
await _showIncomingRequestNotificationFromMessage(plugin, message);
}
Future<void> _showIncomingRequestNotificationFromMessage(
FlutterLocalNotificationsPlugin notifications,
RemoteMessage message,
) async {
final data = message.data;
if (data['type'] != 'incoming_call_request') return;
final requestId = (data['requestId'] ?? '').toString();
final userId = (data['userId'] ?? 'User').toString();
final userName = (data['userName'] ?? '').toString();
final userEmail = (data['userEmail'] ?? '').toString();
final id = requestId.isEmpty
? DateTime.now().millisecondsSinceEpoch
: requestId.hashCode;
const details = NotificationDetails(
android: AndroidNotificationDetails(
'incoming_calls',
'Incoming Calls',
channelDescription: 'Incoming queue request alerts',
importance: Importance.max,
priority: Priority.high,
fullScreenIntent: true,
category: AndroidNotificationCategory.call,
icon: 'ic_admin_support_notification',
),
);
await notifications.show(
id,
'Incoming call request',
'${_customerName(userName, userId)} (${_customerSubtitle(userEmail, userId)}) is waiting',
details,
);
}
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
try {
await Firebase.initializeApp();
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
} catch (error, stackTrace) {
debugLog('Firebase initialize failed', error, stackTrace);
}
runApp(const CsnExampleApp());
}
class CsnExampleApp extends StatelessWidget {
const CsnExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: _appName,
debugShowCheckedModeBanner: false,
theme: csnTheme(
brightness: Brightness.dark,
override: CsnThemeData.dark().copyWith(
primary: const Color(0xFF22C3EE),
accent: const Color(0xFF64D2FF),
background: const Color(0xFF080D17),
surface: const Color(0xFF111827),
text: const Color(0xFFE6EDF7),
mutedText: const Color(0xFF9FB0C9),
),
),
home: const CsnHomePage(),
);
}
}
class CsnHomePage extends StatefulWidget {
const CsnHomePage({super.key});
@override
State<CsnHomePage> createState() => _CsnHomePageState();
}
class _CsnHomePageState extends State<CsnHomePage> {
static const String _baseUrl = 'https://api.deafott.com/';
static const String _wsUrl = 'wss://api.deafott.com/ws';
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
_StaffRole _selectedRole = _StaffRole.executive;
String? _loginError;
bool _authenticated = false;
bool _sessionReady = false;
bool _loadingLogin = false;
bool _loadingAdminData = false;
bool _changingAdminPassword = false;
final _notifications = FlutterLocalNotificationsPlugin();
final Set<String> _seenQueueIds = <String>{};
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
CsnAdminRequestController? _adminController;
String? _adminJwt;
String? _currentUserId;
String? _currentUserName;
String? _currentUserEmail;
_StaffRole? _currentRole;
List<AuthUserProfile> _executives = [];
List<StaffHistoryItem> _history = [];
List<StaffHistoryItem> _executiveHistory = [];
AdminStatsResponse? _adminStats;
String? _activeRequestId;
String? _fcmToken;
bool _joiningCall = false;
bool _connectingAdmin = false;
bool _permissionsRequested = false;
bool _firebaseReady = false;
StreamSubscription<RemoteMessage>? _onMessageSub;
StreamSubscription<String>? _onTokenRefreshSub;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
await _restoreSession();
await _initNotifications();
await _initFirebaseMessaging();
await _requestMediaPermissionsOnce();
if (mounted) {
setState(() {
_sessionReady = true;
});
}
});
}
Future<void> _initNotifications() async {
const settings = InitializationSettings(
android: AndroidInitializationSettings(
'@drawable/ic_admin_support_notification'),
);
await _notifications.initialize(settings);
final android = _notifications.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
await android?.requestNotificationsPermission();
const channel = AndroidNotificationChannel(
'incoming_calls',
'Incoming Calls',
description: 'Incoming queue request alerts',
importance: Importance.max,
);
await android?.createNotificationChannel(channel);
}
Future<void> _initFirebaseMessaging() async {
try {
await Firebase.initializeApp();
_firebaseReady = true;
} catch (error, stackTrace) {
debugLog('Firebase init unavailable', error, stackTrace);
_firebaseReady = false;
return;
}
await _messaging.requestPermission(
alert: true,
badge: true,
sound: true,
);
_fcmToken = await _messaging.getToken();
if (_fcmToken != null && _fcmToken!.isNotEmpty) {
await _registerAdminPushTokenIfPossible(_fcmToken!);
}
_onTokenRefreshSub = _messaging.onTokenRefresh.listen((token) async {
_fcmToken = token;
await _registerAdminPushTokenIfPossible(token);
});
_onMessageSub = FirebaseMessaging.onMessage.listen((message) async {
await _showIncomingRequestNotificationFromMessage(
_notifications, message);
});
}
Future<void> _requestMediaPermissionsOnce() async {
if (_permissionsRequested) return;
_permissionsRequested = true;
try {
final stream = await navigator.mediaDevices.getUserMedia({
'audio': true,
'video': true,
});
for (final track in stream.getTracks()) {
await track.stop();
}
await stream.dispose();
} catch (error, stackTrace) {
debugLog('Media permission request failed', error, stackTrace);
}
}
@override
void dispose() {
unawaited(_onMessageSub?.cancel());
unawaited(_onTokenRefreshSub?.cancel());
final adminJwt = (_adminJwt ?? '').trim();
final token = _fcmToken;
if (_currentRole == _StaffRole.executive &&
adminJwt.isNotEmpty &&
token != null &&
token.isNotEmpty) {
final api = CsnApiClient(baseUrl: _baseUrl, jwt: adminJwt);
unawaited(api.unregisterAdminPushToken(token).whenComplete(api.close));
}
_emailController.dispose();
_passwordController.dispose();
_adminController?.dispose();
super.dispose();
}
Future<void> _handleLogin() async {
if (_loadingLogin) return;
final email = _emailController.text.trim();
final password = _passwordController.text;
if (email.isEmpty || password.isEmpty) {
setState(() {
_loginError = 'Email and password are required';
});
return;
}
_loadingLogin = true;
setState(() {});
try {
final client = CsnApiClient(baseUrl: _baseUrl);
final login = _selectedRole == _StaffRole.admin
? await client.loginAdmin(email: email, password: password)
: await client.loginExecutive(email: email, password: password);
client.close();
_adminJwt = login.token;
_currentUserId = login.user.id;
_currentUserName = login.user.name;
_currentUserEmail = login.user.email;
_currentRole = _selectedRole;
await _saveSession();
setState(() {
_authenticated = true;
_loginError = null;
});
if (_selectedRole == _StaffRole.executive) {
await _connectExecutive();
} else {
await _loadAdminDashboard();
}
} catch (error, stackTrace) {
debugLog('Staff login failed', error, stackTrace);
setState(() {
_loginError = 'Invalid email or password';
});
} finally {
_loadingLogin = false;
if (mounted) setState(() {});
}
}
Future<void> _saveSession() async {
final token = _adminJwt?.trim() ?? '';
final role = _currentRole;
final userId = _currentUserId?.trim() ?? '';
if (token.isEmpty || role == null || userId.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_sessionTokenKey, token);
await prefs.setString(_sessionRoleKey, role.name);
await prefs.setString(_sessionUserIdKey, userId);
await prefs.setString(_sessionUserNameKey, _currentUserName ?? '');
await prefs.setString(_sessionUserEmailKey, _currentUserEmail ?? '');
}
Future<void> _clearSession() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_sessionTokenKey);
await prefs.remove(_sessionRoleKey);
await prefs.remove(_sessionUserIdKey);
await prefs.remove(_sessionUserNameKey);
await prefs.remove(_sessionUserEmailKey);
}
Future<void> _restoreSession() async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(_sessionTokenKey)?.trim() ?? '';
final roleValue = prefs.getString(_sessionRoleKey)?.trim() ?? '';
if (token.isEmpty || roleValue.isEmpty || _isJwtExpired(token)) {
await _clearSession();
return;
}
final role = _StaffRole.values.where((it) => it.name == roleValue).toList();
if (role.isEmpty) {
await _clearSession();
return;
}
_adminJwt = token;
_currentRole = role.first;
_selectedRole = role.first;
_currentUserId = prefs.getString(_sessionUserIdKey);
_currentUserName = prefs.getString(_sessionUserNameKey);
_currentUserEmail = prefs.getString(_sessionUserEmailKey);
_authenticated = true;
if (_currentRole == _StaffRole.executive) {
await _connectExecutive();
} else {
await _loadAdminDashboard();
}
}
bool _isJwtExpired(String token) {
try {
final parts = token.split('.');
if (parts.length < 2) return true;
final payload = parts[1];
final normalized = base64Url.normalize(payload);
final json = jsonDecode(utf8.decode(base64Url.decode(normalized)));
if (json is! Map<String, dynamic>) return true;
final exp = json['exp'];
if (exp is! num) return true;
final expiresAt = DateTime.fromMillisecondsSinceEpoch(exp.toInt() * 1000);
return DateTime.now().isAfter(expiresAt);
} catch (_) {
return true;
}
}
Future<void> _connectExecutive() async {
if (_connectingAdmin) return;
_connectingAdmin = true;
setState(() {});
try {
if ((_adminJwt ?? '').isEmpty) {
_showToast('Login required');
return;
}
_adminController?.dispose();
_adminController = CsnAdminRequestController(
baseUrl: _baseUrl,
adminJwt: _adminJwt!,
wsUrl: _normalizeWsUrl(_wsUrl),
);
_adminController!.addListener(_onAdminUpdate);
await _registerAdminPushTokenIfPossible(_fcmToken);
await _adminController!.refreshQueue();
final api = CsnApiClient(baseUrl: _baseUrl, jwt: _adminJwt);
_executiveHistory = await api.getExecutiveHistory(limit: 150);
api.close();
} catch (error, stackTrace) {
debugLog('Connect executive failed', error, stackTrace);
final message = error.toString();
if (message.contains('401')) {
_showToast('Session expired. Please login again.');
_logout();
} else {
_showToast('Failed to connect executive');
}
} finally {
_connectingAdmin = false;
if (mounted) setState(() {});
}
}
Future<void> _loadAdminDashboard() async {
final jwt = (_adminJwt ?? '').trim();
if (jwt.isEmpty) return;
_loadingAdminData = true;
setState(() {});
try {
final api = CsnApiClient(baseUrl: _baseUrl, jwt: jwt);
final executives = await api.listExecutives();
final history = await api.getAdminHistory(limit: 300);
final stats = await api.getAdminStats();
api.close();
_executives = executives;
_history = history;
_adminStats = stats;
} catch (error, stackTrace) {
debugLog('Load admin dashboard failed', error, stackTrace);
final message = error.toString();
if (message.contains('401')) {
_showToast('Session expired. Please login again.');
_logout();
} else {
_showToast('Failed to load admin dashboard');
}
} finally {
_loadingAdminData = false;
if (mounted) setState(() {});
}
}
Future<void> _openExecutiveManagementPage() async {
final jwt = (_adminJwt ?? '').trim();
if (jwt.isEmpty) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ExecutiveManagementPage(
baseUrl: _baseUrl,
jwt: jwt,
),
),
);
await _loadAdminDashboard();
}
Future<void> _changeOwnAdminPassword() async {
if (_changingAdminPassword) return;
final jwt = (_adminJwt ?? '').trim();
if (jwt.isEmpty) return;
final values = await _showChangePasswordDialog();
if (values == null) return;
final currentPassword = values.$1.trim();
final newPassword = values.$2.trim();
if (currentPassword.isEmpty || newPassword.isEmpty) {
_showToast('Current and new password are required');
return;
}
_changingAdminPassword = true;
if (mounted) setState(() {});
try {
final api = CsnApiClient(baseUrl: _baseUrl, jwt: jwt);
await api.changeAdminPassword(
currentPassword: currentPassword,
newPassword: newPassword,
);
api.close();
_showToast('Password changed successfully');
} catch (error, stackTrace) {
debugLog('Change admin password failed', error, stackTrace);
_showToast('Failed to change password');
} finally {
_changingAdminPassword = false;
if (mounted) setState(() {});
}
}
Future<(String, String)?> _showChangePasswordDialog() async {
var currentPassword = '';
var newPassword = '';
final result = await showDialog<(String, String)>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Change My Password'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
obscureText: true,
decoration: const InputDecoration(
labelText: 'Current password',
),
onChanged: (value) => currentPassword = value,
),
const SizedBox(height: 10),
TextField(
obscureText: true,
decoration: const InputDecoration(
labelText: 'New password',
),
onChanged: (value) => newPassword = value,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.of(dialogContext).pop(
(currentPassword, newPassword),
),
child: const Text('Change'),
),
],
),
);
return result;
}
Future<void> _onAdminUpdate() async {
final controller = _adminController;
if (controller == null) return;
for (final item in controller.queue) {
if (_seenQueueIds.add(item.requestId)) {
await _showIncomingRequestNotification(item);
}
}
if (mounted) {
setState(() {});
}
}
Future<void> _showIncomingRequestNotification(AdminQueueItem item) async {
const details = NotificationDetails(
android: AndroidNotificationDetails(
'incoming_calls',
'Incoming Calls',
channelDescription: 'Incoming queue request alerts',
importance: Importance.max,
priority: Priority.high,
fullScreenIntent: true,
category: AndroidNotificationCategory.call,
icon: 'ic_admin_support_notification',
),
);
await _notifications.show(
item.requestId.hashCode,
'Incoming call request',
'${_customerName(item.userName, item.userId)} (${_customerSubtitle(item.userEmail, item.userId)}) is waiting',
details,
);
}
Future<void> _registerAdminPushTokenIfPossible(String? token) async {
if (!_firebaseReady) return;
if (_currentRole != _StaffRole.executive) return;
final adminJwt = (_adminJwt ?? '').trim();
final cleanToken = token?.trim() ?? '';
if (adminJwt.isEmpty || cleanToken.isEmpty) return;
try {
final api = CsnApiClient(baseUrl: _baseUrl, jwt: adminJwt);
await api.registerAdminPushToken(cleanToken);
api.close();
debugLog('Admin FCM token registered');
} catch (error, stackTrace) {
debugLog('Admin FCM token register failed', error, stackTrace);
}
}
Future<void> _accept(AdminQueueItem item) async {
final admin = _adminController;
if (admin == null) return;
final resolvedRequestType = await _resolveRequestType(item);
final roomId = await admin.accept(item.requestId);
if (roomId == null) return;
_activeRequestId = item.requestId;
Future<void> onEnded() async {
if (_activeRequestId != null) {
await admin.end(_activeRequestId!);
_activeRequestId = null;
final jwt = (_adminJwt ?? '').trim();
if (jwt.isNotEmpty) {
final api = CsnApiClient(baseUrl: _baseUrl, jwt: jwt);
_executiveHistory = await api.getExecutiveHistory(limit: 150);
api.close();
}
}
}
if (resolvedRequestType == CsnSupportRequestType.liveChat) {
await _openLiveChat(
jwt: (_adminJwt ?? '').trim(),
roomId: roomId,
requestId: item.requestId,
localUserId: _currentUserId ?? 'executive',
onEnded: onEnded,
);
return;
}
await _openCall(
jwt: (_adminJwt ?? '').trim(),
roomId: roomId,
localUserId: _currentUserId ?? 'executive',
onEnded: onEnded,
);
}
Future<CsnSupportRequestType?> _resolveRequestType(
AdminQueueItem item) async {
if (item.requestType != null) return item.requestType;
final jwt = (_adminJwt ?? '').trim();
if (jwt.isEmpty) return null;
try {
final api = CsnApiClient(baseUrl: _baseUrl, jwt: jwt);
final status = await api.getCallRequestStatus(item.requestId);
api.close();
return status.requestType;
} catch (error, stackTrace) {
debugLog('Resolve request type failed', error, stackTrace);
return null;
}
}
Future<void> _openCall({
required String jwt,
required String roomId,
required String localUserId,
required Future<void> Function() onEnded,
}) async {
if (_joiningCall) return;
_joiningCall = true;
try {
await _requestMediaPermissionsOnce();
final sdk = CsnSdk(
baseUrl: _baseUrl,
wsUrl: _normalizeWsUrl(_wsUrl),
jwt: jwt,
);
final controller = CsnBasicCallController(
apiClient: sdk.api,
signalingClient: sdk.signaling(),
localUserId: localUserId,
);
await controller.initialize();
await controller.join(roomId);
if (!mounted) {
await controller.leave();
controller.dispose();
return;
}
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => CsnCallScreen(
controller: controller,
onEndCall: () {
unawaited(onEnded());
},
),
),
);
controller.dispose();
} catch (error, stackTrace) {
debugLog('Open call failed', error, stackTrace);
_showToast('Failed to start call');
} finally {
_joiningCall = false;
}
}
Future<void> _openLiveChat({
required String jwt,
required String roomId,
required String requestId,
required String localUserId,
required Future<void> Function() onEnded,
}) async {
if (_joiningCall) return;
_joiningCall = true;
try {
final sdk = CsnSdk(
baseUrl: _baseUrl,
wsUrl: _normalizeWsUrl(_wsUrl),
jwt: jwt,
);
final controller = CsnLiveChatController(
signalingClient: sdk.signaling(),
localUserId: localUserId,
roomId: roomId,
requestId: requestId,
);
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => CsnLiveChatScreen(
controller: controller,
title: 'Support Chat',
onEndChat: onEnded,
),
),
);
controller.dispose();
} catch (error, stackTrace) {
debugLog('Open live chat failed', error, stackTrace);
_showToast('Failed to start live chat');
} finally {
_joiningCall = false;
}
}
void _showToast(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
}
String _normalizeWsUrl(String raw) {
final trimmed = raw.trim();
if (trimmed.startsWith('ws://') || trimmed.startsWith('wss://')) {
return trimmed;
}
if (trimmed.startsWith('http://')) {
return trimmed.replaceFirst('http://', 'ws://');
}
if (trimmed.startsWith('https://')) {
return trimmed.replaceFirst('https://', 'wss://');
}
return trimmed;
}
Widget _buildLoginPage(CsnThemeData theme) {
return Scaffold(
backgroundColor: theme.background,
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF050914), Color(0xFF111827), Color(0xFF0C162B)],
),
),
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xAA0F172A),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFF2E3E5B)),
boxShadow: const [
BoxShadow(
color: Color(0x55000000),
blurRadius: 22,
offset: Offset(0, 12),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const _SupportLogo(size: 80),
const SizedBox(height: 14),
Text(
_appName,
textAlign: TextAlign.center,
style: TextStyle(
color: theme.text,
fontSize: 24,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Text(
'Staff Login',
style: TextStyle(color: theme.mutedText),
),
const SizedBox(height: 12),
SegmentedButton<_StaffRole>(
segments: const [
ButtonSegment<_StaffRole>(
value: _StaffRole.executive,
icon: Icon(Icons.support_agent),
label: Text('Executive'),
),
ButtonSegment<_StaffRole>(
value: _StaffRole.admin,
icon: Icon(Icons.admin_panel_settings),
label: Text('Admin'),
),
],
selected: {_selectedRole},
onSelectionChanged: (value) {
setState(() {
_selectedRole = value.first;
});
},
),
const SizedBox(height: 18),
TextField(
controller: _emailController,
style: TextStyle(color: theme.text),
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined),
),
),
const SizedBox(height: 12),
TextField(
controller: _passwordController,
obscureText: true,
style: TextStyle(color: theme.text),
decoration: const InputDecoration(
labelText: 'Password',
prefixIcon: Icon(Icons.lock_outline),
),
onSubmitted: (_) => unawaited(_handleLogin()),
),
if (_loginError != null) ...[
const SizedBox(height: 10),
Text(
_loginError!,
style: const TextStyle(color: Color(0xFFF87171)),
),
],
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _loadingLogin
? null
: () => unawaited(_handleLogin()),
icon: const Icon(Icons.login),
label: Text(_loadingLogin ? 'Logging in...' : 'Login'),
),
),
],
),
),
),
),
),
),
);
}
void _logout() {
unawaited(_clearSession());
_adminController?.dispose();
_adminController = null;
setState(() {
_authenticated = false;
_loginError = null;
_passwordController.clear();
_adminJwt = null;
_currentUserId = null;
_currentUserName = null;
_currentUserEmail = null;
_currentRole = null;
_executives = [];
_history = [];
_executiveHistory = [];
_adminStats = null;
});
}
Widget _buildExecutivePage(CsnThemeData theme) {
final admin = _adminController;
return Scaffold(
appBar: AppBar(
backgroundColor: theme.surface,
titleSpacing: 12,
title: const Row(
children: [
_SupportLogo(size: 34),
SizedBox(width: 10),
Expanded(child: Text(_appName, overflow: TextOverflow.ellipsis)),
],
),
actions: [
IconButton(
tooltip: 'Logout',
onPressed: _logout,
icon: const Icon(Icons.logout),
),
],
),
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF080D17), Color(0xFF0F172A)],
),
),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xCC111827),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF2A3A52)),
),
child: Column(
children: [
Wrap(
spacing: 10,
runSpacing: 10,
children: [
ElevatedButton.icon(
onPressed: _connectingAdmin ? null : _connectExecutive,
icon: const Icon(Icons.support_agent),
label: Text(
_connectingAdmin
? 'Connecting...'
: 'Connect Executive',
),
),
OutlinedButton.icon(
onPressed: admin?.refreshQueue,
icon: const Icon(Icons.refresh),
label: const Text('Refresh Queue'),
),
],
),
const SizedBox(height: 12),
_InfoRow(label: 'Executive', value: _currentUserName ?? '-'),
_InfoRow(
label: 'Live',
value:
admin?.connected == true ? 'Connected' : 'Disconnected',
),
_InfoRow(
label: 'Active Calls',
value: admin?.activeCount.toString() ?? '-',
),
_InfoRow(
label: 'Avg Call Time',
value: admin == null
? '-'
: _formatDurationSeconds(admin.averageCallSeconds),
),
],
),
),
const SizedBox(height: 14),
for (final item in admin?.queue ?? const <AdminQueueItem>[])
Card(
color: const Color(0xFF141C2E),
child: ListTile(
title: Text(
_customerName(item.userName, item.userId),
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text(
'${_customerSubtitle(item.userEmail, item.userId)}'
' | ${_requestTypeLabel(item.requestType)}'
' | Pos ${item.position} | ETA ${_formatDurationSeconds(item.etaSeconds)}'
' | Requested ${_formatExactTime(item.createdAt)}',
),
trailing: Wrap(
spacing: 8,
children: [
TextButton(
onPressed: () => _accept(item),
child: const Text('Accept'),
),
TextButton(
onPressed: () => admin?.decline(item.requestId),
child: const Text('Decline'),
),
],
),
),
),
const SizedBox(height: 16),
const Text(
'My History',
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 16),
),
const SizedBox(height: 8),
for (final item in _executiveHistory)
Card(
color: const Color(0xFF141C2E),
child: ListTile(
title: Text(_customerName(item.userName, item.userId)),
subtitle: Text(
'${_customerSubtitle(item.userEmail, item.userId)}'
' | ${_requestTypeLabel(item.requestType)} | Status: ${item.status}'
' | Time: ${_formatDurationSeconds(item.timeTakenSeconds)}'
' | Started: ${_formatExactTime(item.acceptedAt ?? item.createdAt)}'
' | Ended: ${_formatExactTime(item.endedAt)}',
),
),
),
],
),
),
);
}
Widget _buildStatsSection(String title, List<StaffStatsItem> items,
{required bool monthOnly, bool showExecutive = false}) {
final visibleItems = items.take(12).toList();
return Card(
color: const Color(0xFF141C2E),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 16),
),
const SizedBox(height: 8),
if (visibleItems.isEmpty)
const Text('No records yet')
else
for (final item in visibleItems)
_InfoRow(
label: showExecutive
? '${item.executiveName ?? 'Unassigned'} | ${_formatPeriod(item.periodStart, monthOnly: monthOnly)}'
: _formatPeriod(item.periodStart, monthOnly: monthOnly),
value:
'${_requestTypeLabel(item.requestType)}: ${item.total}',
),
],
),
),
);
}
Widget _buildAdminPage(CsnThemeData theme) {
final stats = _adminStats;
final today =
_formatPeriod(DateTime.now().toIso8601String(), monthOnly: false);
return Scaffold(
appBar: AppBar(
backgroundColor: theme.surface,
title: const Text('Admin Dashboard'),
actions: [
IconButton(
tooltip: 'Change Password',
onPressed: _changingAdminPassword
? null
: () => unawaited(_changeOwnAdminPassword()),
icon: const Icon(Icons.password),
),
IconButton(
tooltip: 'Refresh',
onPressed: _loadingAdminData ? null : _loadAdminDashboard,
icon: const Icon(Icons.refresh),
),
IconButton(
tooltip: 'Logout',
onPressed: _logout,
icon: const Icon(Icons.logout),
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xCC111827),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF2A3A52)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_InfoRow(label: 'Admin', value: _currentUserName ?? '-'),
_InfoRow(label: 'Email', value: _currentUserEmail ?? '-'),
_InfoRow(
label: 'Executives',
value: _executives.length.toString(),
),
_InfoRow(
label: 'History Items',
value: _history.length.toString(),
),
if (stats != null) ...[
_InfoRow(
label: 'Calls Today',
value: _statsTotalForPeriod(
stats.daily,
period: today,
requestType: CsnSupportRequestType.videoCall,
).toString(),
),
_InfoRow(
label: 'Chats Today',
value: _statsTotalForPeriod(
stats.daily,
period: today,
requestType: CsnSupportRequestType.liveChat,
).toString(),
),
_InfoRow(
label: 'Records',
value: 'Last ${stats.retentionDays} days',
),
],
],
),
),
const SizedBox(height: 16),
if (stats != null) ...[
_buildStatsSection(
'Daily Call / Chat Records',
stats.daily,
monthOnly: false,
),
const SizedBox(height: 12),
_buildStatsSection(
'Monthly Call / Chat Records',
stats.monthly,
monthOnly: true,
),
const SizedBox(height: 12),
_buildStatsSection(
'Executive Daily Records',
stats.executiveDaily,
monthOnly: false,
showExecutive: true,
),
const SizedBox(height: 12),
_buildStatsSection(
'Executive Monthly Records',
stats.executiveMonthly,
monthOnly: true,
showExecutive: true,
),
const SizedBox(height: 16),
],
Card(
color: const Color(0xFF141C2E),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Executive Management',
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 16),
),
const SizedBox(height: 8),
const Text(
'Open dedicated page to add, activate/deactivate, reset password and delete executives.',
),
const SizedBox(height: 12),
ElevatedButton.icon(
onPressed: _openExecutiveManagementPage,
icon: const Icon(Icons.manage_accounts),
label: const Text('Manage Executives'),
),
],
),
),
),
const SizedBox(height: 16),
const Text(
'Call / Chat History',
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 16),
),
const SizedBox(height: 8),
if (_loadingAdminData) const LinearProgressIndicator(),
for (final item in _history)
Card(
color: const Color(0xFF141C2E),
child: ListTile(
title: Text(
'${item.executiveName ?? 'Unassigned'} -> ${_customerName(item.userName, item.userId)}',
),
subtitle: Text(
'${_customerSubtitle(item.userEmail, item.userId)}'
' | ${_requestTypeLabel(item.requestType)} | Status: ${item.status}'
' | Time: ${_formatDurationSeconds(item.timeTakenSeconds)}'
' | Started: ${_formatExactTime(item.acceptedAt ?? item.createdAt)}'
' | Ended: ${_formatExactTime(item.endedAt)}',
),
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final theme = CsnTheme.of(context);
if (!_sessionReady) {
return Scaffold(
backgroundColor: theme.background,
body: const Center(child: CircularProgressIndicator()),
);
}
if (!_authenticated) return _buildLoginPage(theme);
if (_currentRole == _StaffRole.admin) return _buildAdminPage(theme);
return _buildExecutivePage(theme);
}
}
class ExecutiveManagementPage extends StatefulWidget {
const ExecutiveManagementPage({
super.key,
required this.baseUrl,
required this.jwt,
});
final String baseUrl;
final String jwt;
@override
State<ExecutiveManagementPage> createState() =>
_ExecutiveManagementPageState();
}
class _ExecutiveManagementPageState extends State<ExecutiveManagementPage> {
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final Set<String> _updatingExecutiveIds = <String>{};
final Set<String> _deletingExecutiveIds = <String>{};
final Set<String> _resettingExecutiveIds = <String>{};
bool _loading = false;
bool _creating = false;
List<AuthUserProfile> _executives = [];
@override
void initState() {
super.initState();
unawaited(_loadExecutives());
}
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _loadExecutives() async {
_loading = true;
if (mounted) setState(() {});
try {
final api = CsnApiClient(baseUrl: widget.baseUrl, jwt: widget.jwt);
_executives = await api.listExecutives();
api.close();
} catch (error, stackTrace) {
debugLog('Load executives failed', error, stackTrace);
_showToast('Failed to load executives');
} finally {
_loading = false;
if (mounted) setState(() {});
}
}
Future<void> _createExecutive() async {
if (_creating) return;
final name = _nameController.text.trim();
final email = _emailController.text.trim();
final password = _passwordController.text;
if (name.isEmpty || email.isEmpty || password.isEmpty) {
_showToast('Name, email and password are required');
return;
}
_creating = true;
if (mounted) setState(() {});
try {
final api = CsnApiClient(baseUrl: widget.baseUrl, jwt: widget.jwt);
await api.createExecutive(name: name, email: email, password: password);
api.close();
_nameController.clear();
_emailController.clear();
_passwordController.clear();
await _loadExecutives();
_showToast('Executive created');
} catch (error, stackTrace) {
debugLog('Create executive failed', error, stackTrace);
_showToast('Failed to create executive');
} finally {
_creating = false;
if (mounted) setState(() {});
}
}
Future<void> _toggleExecutiveActive(AuthUserProfile executive) async {
if (_updatingExecutiveIds.contains(executive.id)) return;
_updatingExecutiveIds.add(executive.id);
if (mounted) setState(() {});
try {
final api = CsnApiClient(baseUrl: widget.baseUrl, jwt: widget.jwt);
await api.updateExecutiveActive(
executiveId: executive.id,
isActive: !executive.isActive,
);
api.close();
await _loadExecutives();
} catch (error, stackTrace) {
debugLog('Toggle executive active failed', error, stackTrace);
_showToast('Failed to update executive status');
} finally {
_updatingExecutiveIds.remove(executive.id);
if (mounted) setState(() {});
}
}
Future<void> _resetExecutivePassword(AuthUserProfile executive) async {
if (_resettingExecutiveIds.contains(executive.id)) return;
final password = await _showPasswordPrompt(
title: 'Reset Password',
hint: 'New password for ${executive.name}',
actionLabel: 'Reset',
);
if (password == null || password.trim().isEmpty) return;
_resettingExecutiveIds.add(executive.id);
if (mounted) setState(() {});
try {
final api = CsnApiClient(baseUrl: widget.baseUrl, jwt: widget.jwt);
await api.resetExecutivePassword(
executiveId: executive.id,
password: password.trim(),
);
api.close();
_showToast('Password reset for ${executive.name}');
} catch (error, stackTrace) {
debugLog('Reset executive password failed', error, stackTrace);
_showToast('Failed to reset password');
} finally {
_resettingExecutiveIds.remove(executive.id);
if (mounted) setState(() {});
}
}
Future<void> _deleteExecutive(AuthUserProfile executive) async {
if (_deletingExecutiveIds.contains(executive.id)) return;
final confirmed = await _showConfirmDialog(
title: 'Delete Executive',
message:
'Delete ${executive.name}? This removes executive login access immediately.',
actionLabel: 'Delete',
);
if (!confirmed) return;
_deletingExecutiveIds.add(executive.id);
if (mounted) setState(() {});
try {
final api = CsnApiClient(baseUrl: widget.baseUrl, jwt: widget.jwt);
await api.deleteExecutive(executiveId: executive.id);
api.close();
await _loadExecutives();
_showToast('Executive deleted');
} catch (error, stackTrace) {
debugLog('Delete executive failed', error, stackTrace);
_showToast('Failed to delete executive');
} finally {
_deletingExecutiveIds.remove(executive.id);
if (mounted) setState(() {});
}
}
Future<String?> _showPasswordPrompt({
required String title,
required String hint,
required String actionLabel,
}) async {
var value = '';
final result = await showDialog<String>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(title),
content: TextField(
autofocus: true,
obscureText: true,
decoration: InputDecoration(hintText: hint),
onChanged: (text) => value = text,
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.of(dialogContext).pop(value),
child: Text(actionLabel),
),
],
),
);
return result;
}
Future<bool> _showConfirmDialog({
required String title,
required String message,
required String actionLabel,
}) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(actionLabel),
),
],
),
);
return result == true;
}
void _showToast(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Manage Executives'),
actions: [
IconButton(
tooltip: 'Refresh',
onPressed: _loading ? null : _loadExecutives,
icon: const Icon(Icons.refresh),
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
color: const Color(0xFF141C2E),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Add Executive',
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 16),
),
const SizedBox(height: 10),
TextField(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Name'),
),
const SizedBox(height: 10),
TextField(
controller: _emailController,
decoration: const InputDecoration(labelText: 'Email'),
),
const SizedBox(height: 10),
TextField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(labelText: 'Password'),
),
const SizedBox(height: 10),
ElevatedButton.icon(
onPressed:
_creating ? null : () => unawaited(_createExecutive()),
icon: const Icon(Icons.person_add_alt_1),
label: Text(_creating ? 'Creating...' : 'Create Executive'),
),
],
),
),
),
const SizedBox(height: 16),
const Text(
'Executives',
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 16),
),
const SizedBox(height: 8),
if (_loading) const LinearProgressIndicator(),
for (final executive in _executives)
Card(
color: const Color(0xFF141C2E),
margin: const EdgeInsets.only(bottom: 10),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
executive.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Text(
executive.email,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
executive.id,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFF9FB0C9)),
),
const SizedBox(height: 8),
Row(
children: [
Switch(
value: executive.isActive,
activeThumbColor: Colors.white,
activeTrackColor: Colors.green,
inactiveTrackColor: Colors.grey.shade700,
onChanged: _updatingExecutiveIds
.contains(executive.id)
? null
: (_) =>
unawaited(_toggleExecutiveActive(executive)),
),
IconButton(
tooltip: 'Reset password',
onPressed: _resettingExecutiveIds
.contains(executive.id)
? null
: () =>
unawaited(_resetExecutivePassword(executive)),
icon: const Icon(Icons.password),
),
IconButton(
tooltip: 'Delete executive',
onPressed: _deletingExecutiveIds
.contains(executive.id)
? null
: () => unawaited(_deleteExecutive(executive)),
icon: const Icon(Icons.delete_outline),
),
],
),
],
),
),
),
],
),
);
}
}
class _SupportLogo extends StatelessWidget {
const _SupportLogo({required this.size});
final double size;
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF22C3EE), Color(0xFF1E5BFF)],
),
boxShadow: [
BoxShadow(
color: Color(0x5522C3EE),
blurRadius: 16,
offset: Offset(0, 6),
),
],
),
child: Icon(
Icons.support_agent_rounded,
color: Colors.white,
size: size * 0.58,
),
);
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
children: [
Expanded(
child: Text(
label,
style: textTheme.bodyMedium?.copyWith(
color: const Color(0xFF9FB0C9),
),
),
),
Text(
value,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}