479 lines
16 KiB
Dart
479 lines
16 KiB
Dart
import 'dart:io';
|
||
import 'dart:async';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:provider/provider.dart';
|
||
import 'package:chepuhagram/logic/auth_provider.dart';
|
||
import 'package:chepuhagram/data/models/session_model.dart';
|
||
import 'package:chepuhagram/presentation/screens/qr_scan_screen.dart';
|
||
import 'package:timeago/timeago.dart' as timeago;
|
||
|
||
class SessionsScreen extends StatefulWidget {
|
||
const SessionsScreen({super.key});
|
||
|
||
@override
|
||
State<SessionsScreen> createState() => _SessionsScreenState();
|
||
}
|
||
|
||
class _SessionsScreenState extends State<SessionsScreen> {
|
||
Timer? _refreshTimer;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (mounted) {
|
||
Provider.of<AuthProvider>(context, listen: false).fetchSessions();
|
||
}
|
||
});
|
||
// Периодическое обновление списка сессий каждые 5 секунд (фоновое)
|
||
_refreshTimer = Timer.periodic(const Duration(seconds: 5), (_) {
|
||
if (mounted) {
|
||
Provider.of<AuthProvider>(context, listen: false).fetchSessions(silent: true);
|
||
}
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_refreshTimer?.cancel();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final colorScheme = Theme.of(context).colorScheme;
|
||
return Scaffold(
|
||
backgroundColor: colorScheme.background,
|
||
appBar: AppBar(
|
||
title: Text(
|
||
'Активные сеансы',
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.bold,
|
||
color: Theme.of(context).colorScheme.onSurface,
|
||
),
|
||
),
|
||
iconTheme: IconThemeData(
|
||
color: Theme.of(context).colorScheme.onSurface,
|
||
),
|
||
elevation: 0,
|
||
backgroundColor: Colors.transparent,
|
||
),
|
||
body: Consumer<AuthProvider>(
|
||
builder: (context, auth, child) {
|
||
if (auth.isLoading && auth.sessions.isEmpty) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
|
||
if (auth.error != null) {
|
||
return Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16.0),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Text('Ошибка: ${auth.error}', textAlign: TextAlign.center),
|
||
const SizedBox(height: 20),
|
||
ElevatedButton(
|
||
onPressed: () => auth.fetchSessions(),
|
||
child: const Text('Попробовать снова'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
return RefreshIndicator(
|
||
onRefresh: () => auth.fetchSessions(),
|
||
child: _SessionsList(sessions: auth.sessions),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SessionsList extends StatelessWidget {
|
||
final List<Session> sessions;
|
||
|
||
const _SessionsList({required this.sessions});
|
||
|
||
Future<void> _revokeSession(BuildContext context, int sessionId) async {
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: const Text('Завершить сеанс?'),
|
||
content: const Text(
|
||
'Вы уверены, что хотите завершить этот сеанс? Это действие нельзя будет отменить.',
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(context).pop(false),
|
||
child: const Text('Отмена'),
|
||
),
|
||
TextButton(
|
||
onPressed: () => Navigator.of(context).pop(true),
|
||
style: TextButton.styleFrom(
|
||
foregroundColor: Theme.of(context).colorScheme.error,
|
||
),
|
||
child: const Text('Завершить'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
if (confirmed == true && context.mounted) {
|
||
await Provider.of<AuthProvider>(
|
||
context,
|
||
listen: false,
|
||
).revokeSession(sessionId);
|
||
}
|
||
}
|
||
|
||
Future<void> _revokeAllOtherSessions(BuildContext context) async {
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: const Text('Завершить все другие сеансы?'),
|
||
content: const Text(
|
||
'Это завершит все сеансы на всех устройствах, кроме текущего.',
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(context).pop(false),
|
||
child: const Text('Отмена'),
|
||
),
|
||
TextButton(
|
||
onPressed: () => Navigator.of(context).pop(true),
|
||
style: TextButton.styleFrom(
|
||
foregroundColor: Theme.of(context).colorScheme.error,
|
||
),
|
||
child: const Text('Завершить'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
if (confirmed == true && context.mounted) {
|
||
await Provider.of<AuthProvider>(
|
||
context,
|
||
listen: false,
|
||
).revokeAllOtherSessions();
|
||
}
|
||
}
|
||
|
||
IconData _getIconForDevice(String deviceName) {
|
||
final name = deviceName.toLowerCase();
|
||
if (name.contains('windows') ||
|
||
name.contains('linux') ||
|
||
name.contains('macos')) {
|
||
return Icons.desktop_windows_outlined;
|
||
}
|
||
if (name.contains('web') || name.contains('chrome')) {
|
||
return Icons.language_outlined;
|
||
}
|
||
if (name.contains('android') ||
|
||
name.contains('ios') ||
|
||
name.contains('mobile')) {
|
||
return Icons.phone_android_outlined;
|
||
}
|
||
return Icons.device_unknown_outlined;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final Session? currentSession = sessions.isEmpty
|
||
? null
|
||
: sessions.firstWhere((s) => s.isCurrent, orElse: () => sessions.first);
|
||
final otherSessions = sessions
|
||
.where((s) => s.id != currentSession?.id)
|
||
.toList();
|
||
|
||
final colorScheme = Theme.of(context).colorScheme;
|
||
|
||
return ListView(
|
||
padding: EdgeInsets.zero,
|
||
children: [
|
||
if (Platform.isAndroid) ...[
|
||
const SizedBox(height: 16),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: colorScheme.surfaceVariant.withOpacity(0.2),
|
||
borderRadius: BorderRadius.circular(24),
|
||
border: Border.all(
|
||
color: colorScheme.outlineVariant.withOpacity(0.15),
|
||
),
|
||
),
|
||
child: Material(
|
||
color: Colors.transparent,
|
||
child: InkWell(
|
||
borderRadius: BorderRadius.circular(24),
|
||
onTap: () {
|
||
Navigator.push(
|
||
context,
|
||
MaterialPageRoute(builder: (_) => const QrScanScreen()),
|
||
);
|
||
},
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(20.0),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
padding: const EdgeInsets.all(10),
|
||
decoration: BoxDecoration(
|
||
color: colorScheme.primary.withOpacity(0.1),
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: Icon(
|
||
Icons.qr_code_scanner,
|
||
color: colorScheme.primary,
|
||
size: 24,
|
||
),
|
||
),
|
||
const SizedBox(width: 16),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'Подключить устройство',
|
||
style: TextStyle(
|
||
color: colorScheme.onSurface,
|
||
fontWeight: FontWeight.bold,
|
||
fontSize: 16,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
'Сканируйте QR-код для входа на ПК или планшете',
|
||
style: TextStyle(
|
||
color: colorScheme.outline,
|
||
fontSize: 12,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Icon(Icons.chevron_right, color: colorScheme.outline),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
const SizedBox(height: 16),
|
||
if (currentSession != null) ...[
|
||
_buildSectionHeader(context, 'Текущий сеанс'),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||
child: _buildGroupContainer(
|
||
context,
|
||
child: _buildSessionTile(
|
||
context,
|
||
currentSession,
|
||
isCurrent: true,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
const SizedBox(height: 24),
|
||
if (otherSessions.isNotEmpty) ...[
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||
child: _buildGroupContainer(
|
||
context,
|
||
isError: true,
|
||
child: ListTile(
|
||
contentPadding: const EdgeInsets.symmetric(
|
||
horizontal: 20,
|
||
vertical: 4,
|
||
),
|
||
leading: Icon(
|
||
Icons.phonelink_erase_outlined,
|
||
color: Theme.of(context).colorScheme.error,
|
||
),
|
||
title: Text(
|
||
'Завершить все остальные сеансы',
|
||
style: TextStyle(
|
||
color: Theme.of(context).colorScheme.error,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
onTap: () => _revokeAllOtherSessions(context),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 24),
|
||
_buildSectionHeader(context, 'Активные сеансы'),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||
child: _buildGroupContainer(
|
||
context,
|
||
child: Column(
|
||
children: otherSessions.map((session) {
|
||
return Column(
|
||
children: [
|
||
_buildSessionTile(context, session),
|
||
if (otherSessions.last != session) _buildDivider(context),
|
||
],
|
||
);
|
||
}).toList(),
|
||
),
|
||
),
|
||
),
|
||
] else if (sessions.isNotEmpty) ...[
|
||
Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16.0),
|
||
child: Text(
|
||
'Нет других активных сеансов.',
|
||
style: Theme.of(context).textTheme.bodySmall,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
if (sessions.isEmpty &&
|
||
!Provider.of<AuthProvider>(context, listen: false).isLoading)
|
||
Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(32.0),
|
||
child: Text(
|
||
'Нет информации о сеансах.',
|
||
style: Theme.of(context).textTheme.bodySmall,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildSessionTile(
|
||
BuildContext context,
|
||
Session session, {
|
||
bool isCurrent = false,
|
||
}) {
|
||
final colorScheme = Theme.of(context).colorScheme;
|
||
|
||
// Считаем устройство онлайн, если оно текущее или последняя активность была менее 90 секунд назад
|
||
final difference = DateTime.now().toUtc().difference(session.lastActive.toUtc());
|
||
final isOnline = isCurrent || difference.inSeconds < 90;
|
||
|
||
final statusText = isOnline
|
||
? 'В сети'
|
||
: 'Активность: ${timeago.format(session.lastActive.toLocal(), locale: 'ru')}';
|
||
|
||
return ListTile(
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||
leading: Stack(
|
||
clipBehavior: Clip.none,
|
||
children: [
|
||
Container(
|
||
padding: const EdgeInsets.all(8),
|
||
decoration: BoxDecoration(
|
||
color: colorScheme.primary.withOpacity(isCurrent ? 0.12 : 0.08),
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Icon(
|
||
_getIconForDevice(session.deviceName),
|
||
color: colorScheme.primary,
|
||
size: 24,
|
||
),
|
||
),
|
||
Positioned(
|
||
bottom: -2,
|
||
right: -2,
|
||
child: Container(
|
||
width: 12,
|
||
height: 12,
|
||
decoration: BoxDecoration(
|
||
color: isOnline ? Colors.green : Colors.grey,
|
||
shape: BoxShape.circle,
|
||
border: Border.all(
|
||
color: Theme.of(context).scaffoldBackgroundColor,
|
||
width: 2,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
title: Text(
|
||
session.deviceName,
|
||
style: TextStyle(
|
||
fontWeight: isCurrent ? FontWeight.bold : FontWeight.w600,
|
||
fontSize: 16,
|
||
),
|
||
),
|
||
subtitle: Text.rich(
|
||
TextSpan(
|
||
children: [
|
||
TextSpan(text: '${session.ipAddress}\n'),
|
||
TextSpan(
|
||
text: statusText,
|
||
style: TextStyle(
|
||
color: isOnline ? Colors.green : colorScheme.outline,
|
||
fontWeight: isOnline ? FontWeight.w500 : FontWeight.normal,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
style: const TextStyle(fontSize: 13),
|
||
),
|
||
trailing: isCurrent
|
||
? null
|
||
: TextButton(
|
||
onPressed: () => _revokeSession(context, session.id),
|
||
child: Text('Выйти', style: TextStyle(color: colorScheme.error)),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildGroupContainer(
|
||
BuildContext context, {
|
||
required Widget child,
|
||
bool isError = false,
|
||
}) {
|
||
final colorScheme = Theme.of(context).colorScheme;
|
||
return Container(
|
||
decoration: BoxDecoration(
|
||
color: isError
|
||
? colorScheme.errorContainer.withOpacity(0.15)
|
||
: colorScheme.surfaceVariant.withOpacity(0.2),
|
||
borderRadius: BorderRadius.circular(24),
|
||
border: Border.all(
|
||
color: isError
|
||
? colorScheme.errorContainer.withOpacity(0.2)
|
||
: colorScheme.outlineVariant.withOpacity(0.1),
|
||
),
|
||
),
|
||
child: child,
|
||
);
|
||
}
|
||
|
||
Widget _buildDivider(BuildContext context) => Divider(
|
||
height: 1,
|
||
indent: 68,
|
||
color: Theme.of(context).colorScheme.outlineVariant.withOpacity(0.15),
|
||
);
|
||
|
||
Widget _buildSectionHeader(BuildContext context, String title) {
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||
child: Text(
|
||
title.toUpperCase(),
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.bold,
|
||
color: Theme.of(context).colorScheme.primary,
|
||
fontSize: 12,
|
||
letterSpacing: 0.5,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|