Chepuhagram/lib/presentation/screens/sessions_screen.dart

479 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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