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 createState() => _SessionsScreenState(); } class _SessionsScreenState extends State { Timer? _refreshTimer; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { Provider.of(context, listen: false).fetchSessions(); } }); // Периодическое обновление списка сессий каждые 5 секунд (фоновое) _refreshTimer = Timer.periodic(const Duration(seconds: 5), (_) { if (mounted) { Provider.of(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( 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 sessions; const _SessionsList({required this.sessions}); Future _revokeSession(BuildContext context, int sessionId) async { final confirmed = await showDialog( 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( context, listen: false, ).revokeSession(sessionId); } } Future _revokeAllOtherSessions(BuildContext context) async { final confirmed = await showDialog( 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( 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(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, ), ), ); } }