Экран устройств + danon

This commit is contained in:
Jganenokk
2026-04-04 12:10:22 +07:00
parent e78acfdbf2
commit 847b00aabe
3 changed files with 669 additions and 6 deletions
+47
View File
@@ -112,6 +112,35 @@ class LoginSyncParams {
}
}
class SessionInfo {
final int? id;
final String client;
final String location;
final bool current;
final int time;
final String info;
const SessionInfo({
this.id,
required this.client,
required this.location,
required this.current,
required this.time,
required this.info,
});
factory SessionInfo.fromMap(Map<dynamic, dynamic> map) {
return SessionInfo(
id: map['id'],
client: map['client'] ?? '',
location: map['location'] ?? '',
current: map['current'] ?? false,
time: map['time'] ?? 0,
info: map['info'] ?? '',
);
}
}
class LoginResult {
final ProfileData profile;
final String? updatedToken;
@@ -212,6 +241,24 @@ class AccountModule {
);
}
Future<List<SessionInfo>> getSessions() async {
_ensureOnline();
final packet = await _api.sendRequest(Opcode.sessionsInfo, {});
_checkPacketError(packet, 'getSessions');
final data = packet.payload;
if (data is! Map || data['sessions'] is! List) return [];
final sessions = data['sessions'] as List;
return sessions
.map((s) => SessionInfo.fromMap(s as Map<dynamic, dynamic>))
.toList();
}
Future<void> terminateOtherSessions() async {
_ensureOnline();
final packet = await _api.sendRequest(Opcode.sessionsClose, {});
_checkPacketError(packet, 'terminateOtherSessions');
}
Future<ProfileData> switchAccount(int accountId) async {
final profile = await AppDatabase.loadProfile(accountId);
if (profile == null) {
@@ -0,0 +1,600 @@
import 'dart:io';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../main.dart' show accountModule;
import '../../../backend/modules/account.dart' show SessionInfo;
import '../../widgets/custom_notification.dart';
class DevicesScreen extends StatefulWidget {
const DevicesScreen({super.key});
@override
State<DevicesScreen> createState() => _DevicesScreenState();
}
class _DevicesScreenState extends State<DevicesScreen>
with SingleTickerProviderStateMixin {
bool _isLoading = true;
List<SessionInfo> _sessions = [];
final Map<int, Map<String, dynamic>> _ipDetails = {};
final Set<int> _loadingIps = {};
final Set<int> _expandedSessions = {};
late AnimationController _shimmerController;
@override
void initState() {
super.initState();
_shimmerController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat();
_loadSessions();
}
@override
void dispose() {
_shimmerController.dispose();
super.dispose();
}
Future<void> _loadSessions() async {
try {
final sessions = await accountModule.getSessions();
if (mounted) {
setState(() {
_sessions = sessions;
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
showCustomNotification(context, 'Ошибка загрузки: $e');
setState(() => _isLoading = false);
}
}
}
Future<void> _terminateOthers() async {
try {
await accountModule.terminateOtherSessions();
if (mounted) {
showCustomNotification(context, 'Все сессии завершены');
_loadSessions();
}
} catch (e) {
if (mounted) {
showCustomNotification(context, 'Ошибка: $e');
}
}
}
Future<void> _lookupIp(int id, String location) async {
if (_ipDetails.containsKey(id)) {
setState(() => _expandedSessions.add(id));
return;
}
final reg = RegExp(r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b');
final match = reg.firstMatch(location);
if (match == null) return;
final ip = match.group(0)!;
if (mounted) {
setState(() => _loadingIps.add(id));
}
try {
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 5);
final request = await client.getUrl(
Uri.parse(
'http://ip-api.com/json/$ip?fields=status,message,country,city,isp,as,mobile,proxy,timezone',
),
);
final response = await request.close();
if (response.statusCode == 200) {
final body = await response.transform(utf8.decoder).join();
final data = jsonDecode(body);
if (mounted) {
setState(() {
_ipDetails[id] = data;
_expandedSessions.add(id);
_loadingIps.remove(id);
});
}
}
} catch (e) {
if (mounted) {
setState(() => _loadingIps.remove(id));
showCustomNotification(context, 'Ошибка IP: $e');
}
}
}
String _formatTime(int timestamp) {
if (timestamp == 0) return '';
final now = DateTime.now();
final date = DateTime.fromMillisecondsSinceEpoch(timestamp);
if (now.year == date.year &&
now.month == date.month &&
now.day == date.day) {
return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
}
final months = [
'янв.',
'февр.',
'мар.',
'апр.',
'мая',
'июня',
'июля',
'авг.',
'сент.',
'окт.',
'нояб.',
'дек.',
];
if (now.year == date.year) {
return '${date.day} ${months[date.month - 1]}';
}
return '${date.day}.${date.month.toString().padLeft(2, '0')}.${date.year}';
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: cs.surface,
appBar: AppBar(
backgroundColor: cs.surface,
elevation: 0,
scrolledUnderElevation: 0,
leading: IconButton(
icon: const Icon(Symbols.chevron_left, size: 28),
onPressed: () => Navigator.pop(context),
),
title: Text(
'Устройства',
style: GoogleFonts.outfit(
fontSize: 20,
fontWeight: FontWeight.w600,
color: cs.onSurface,
),
),
centerTitle: true,
),
body: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
children: [
const SizedBox(height: 16),
_buildPromoCard(context, cs),
const SizedBox(height: 12),
_buildDevicesList(context, cs),
const SizedBox(height: 32),
],
),
),
);
}
Widget _buildPromoCard(BuildContext context, ColorScheme cs) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20),
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(24),
),
child: Center(
child: Column(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: const Color(0xFF15151D),
shape: BoxShape.circle,
border: Border.all(
color: cs.onSurface.withValues(alpha: 0.1),
width: 1,
),
),
child: Icon(Symbols.devices, color: cs.onSurface, size: 28),
),
const SizedBox(height: 16),
Text(
'Устройства в KOMET',
style: GoogleFonts.outfit(
fontSize: 18,
fontWeight: FontWeight.w700,
color: cs.onSurface,
),
),
const SizedBox(height: 8),
Text(
'Кто имеет доступ к вашему аккаунту?',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: cs.onSurfaceVariant.withValues(alpha: 0.7),
height: 1.3,
),
),
],
),
),
);
}
Widget _buildDevicesList(BuildContext context, ColorScheme cs) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(24),
),
child: Column(
children: [
if (_isLoading)
...List.generate(5, (index) => _buildShimmerItem(cs))
else
..._sessions.map(
(session) => _buildDeviceItem(
context,
cs,
id: session.id ?? 0,
title: session.client + (session.current ? ' (текущая)' : ''),
platform: session.info,
location: session.location,
status: session.current ? 'В сети' : null,
time: session.current ? null : _formatTime(session.time),
isOnline: session.current,
),
),
if (!_isLoading) ...[
const SizedBox(height: 12),
Divider(
height: 1,
color: cs.onSurface.withValues(alpha: 0.05),
indent: 20,
endIndent: 20,
),
InkWell(
onTap: _terminateOthers,
borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(24),
),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
vertical: 20,
horizontal: 20,
),
child: Text(
'Завершить все сессии, кроме текущей',
style: TextStyle(
color: cs.error.withValues(alpha: 0.8),
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
),
],
],
),
);
}
Widget _buildShimmerItem(ColorScheme cs) {
return AnimatedBuilder(
animation: _shimmerController,
builder: (context, child) {
final opacity = 0.3 + 0.2 * sin(_shimmerController.value * pi * 2);
return Opacity(
opacity: opacity,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 140,
height: 16,
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
),
const SizedBox(height: 8),
Container(
width: 100,
height: 12,
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
),
const SizedBox(height: 4),
Container(
width: 180,
height: 12,
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
),
],
),
),
Container(
width: 40,
height: 12,
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
),
],
),
),
);
},
);
}
Widget _buildDeviceItem(
BuildContext context,
ColorScheme cs, {
required int id,
required String title,
required String platform,
required String location,
String? status,
String? time,
bool isOnline = false,
}) {
final details = _ipDetails[id];
final isLoading = _loadingIps.contains(id);
final isExpanded = _expandedSessions.contains(id);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: GoogleFonts.outfit(
fontSize: 16,
fontWeight: FontWeight.w700,
color: cs.onSurface,
),
),
const SizedBox(height: 2),
Text(
platform,
style: TextStyle(
fontSize: 13,
color: cs.onSurfaceVariant.withValues(alpha: 0.7),
),
),
Text(
location,
style: TextStyle(
fontSize: 13,
color: cs.onSurfaceVariant.withValues(alpha: 0.7),
),
),
],
),
),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (status != null)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: isOnline ? Colors.greenAccent : cs.outline,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(
status,
style: TextStyle(
color: isOnline
? Colors.greenAccent
: cs.onSurfaceVariant,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
)
else if (time != null)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
time,
style: TextStyle(
color: cs.onSurfaceVariant.withValues(alpha: 0.5),
fontSize: 13,
),
),
),
const SizedBox(height: 8),
if (!isExpanded)
InkWell(
onTap: isLoading ? null : () => _lookupIp(id, location),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(4),
child: isLoading
? SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.onSurfaceVariant.withValues(
alpha: 0.5,
),
),
)
: Icon(
Symbols.add_circle,
size: 20,
color: cs.onSurfaceVariant.withValues(
alpha: 0.4,
),
),
),
),
],
),
],
),
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutQuart,
child: isExpanded && details != null
? Container(
width: double.infinity,
margin: const EdgeInsets.only(top: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: cs.onSurface.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(12),
),
child: Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailRow(
cs,
Symbols.location_city,
'${details['city'] ?? 'Unknown'}, ${details['country'] ?? ''}',
),
_buildDetailRow(
cs,
Symbols.dns,
details['isp'] ?? 'Unknown',
),
_buildDetailRow(
cs,
Symbols.public,
details['as'] ?? 'Unknown',
),
if (details['mobile'] == true)
_buildDetailRow(
cs,
Symbols.stay_current_portrait,
'Мобильная сеть',
color: Colors.blueAccent,
),
if (details['proxy'] == true)
_buildDetailRow(
cs,
Symbols.vpn_lock,
'Обнаружен прокси/VPN',
color: Colors.orangeAccent,
),
_buildDetailRow(
cs,
Symbols.schedule,
details['timezone'] ?? 'Unknown',
),
],
),
Positioned(
right: 0,
bottom: 0,
child: InkWell(
onTap: () =>
setState(() => _expandedSessions.remove(id)),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(4),
child: Icon(
Symbols.do_not_disturb_on,
size: 20,
color: cs.onSurfaceVariant.withValues(
alpha: 0.4,
),
),
),
),
),
],
),
)
: const SizedBox(width: double.infinity, height: 0),
),
],
),
);
}
Widget _buildDetailRow(
ColorScheme cs,
IconData icon,
String text, {
Color? color,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Icon(
icon,
size: 14,
color: color ?? cs.onSurfaceVariant.withValues(alpha: 0.6),
),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: TextStyle(
fontSize: 12,
color: color ?? cs.onSurfaceVariant.withValues(alpha: 0.8),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
+22 -6
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../core/storage/app_database.dart';
import 'devices_screen.dart';
class SettingsTab extends StatefulWidget {
const SettingsTab({super.key});
@@ -69,13 +70,27 @@ class _SettingsTabState extends State<SettingsTab> {
child: _buildSection(
context,
cs,
items: const [
_SettingsItem(
items: [
const _SettingsItem(
icon: Symbols.notifications_active,
label: 'Уведомления и звук',
),
_SettingsItem(icon: Symbols.lock, label: 'Безопасность'),
_SettingsItem(icon: Symbols.devices, label: 'Устройства'),
const _SettingsItem(
icon: Symbols.lock,
label: 'Безопасность',
),
_SettingsItem(
icon: Symbols.devices,
label: 'Устройства',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const DevicesScreen(),
),
);
},
),
],
),
),
@@ -231,7 +246,7 @@ class _SettingsTabState extends State<SettingsTab> {
Material(
color: Colors.transparent,
child: InkWell(
onTap: () {},
onTap: item.onTap ?? () {},
borderRadius: isLast
? const BorderRadius.vertical(bottom: Radius.circular(20))
: null,
@@ -284,8 +299,9 @@ class _SettingsTabState extends State<SettingsTab> {
class _SettingsItem {
final IconData icon;
final String label;
final VoidCallback? onTap;
const _SettingsItem({required this.icon, required this.label});
const _SettingsItem({required this.icon, required this.label, this.onTap});
}
class _PhoneSpoiler extends StatefulWidget {