Improved MacOS support and organized screens and utils
This commit is contained in:
454
lib/screens/cache_management_screen.dart
Normal file
454
lib/screens/cache_management_screen.dart
Normal file
@@ -0,0 +1,454 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gwid/services/cache_service.dart';
|
||||
import 'package:gwid/services/avatar_cache_service.dart';
|
||||
import 'package:gwid/services/chat_cache_service.dart';
|
||||
|
||||
class CacheManagementScreen extends StatefulWidget {
|
||||
const CacheManagementScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CacheManagementScreen> createState() => _CacheManagementScreenState();
|
||||
}
|
||||
|
||||
class _CacheManagementScreenState extends State<CacheManagementScreen> {
|
||||
Map<String, dynamic> _cacheStats = {};
|
||||
Map<String, dynamic> _avatarCacheStats = {};
|
||||
Map<String, dynamic> _chatCacheStats = {};
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadCacheStats();
|
||||
}
|
||||
|
||||
Future<void> _loadCacheStats() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final cacheService = CacheService();
|
||||
final avatarService = AvatarCacheService();
|
||||
final chatService = ChatCacheService();
|
||||
|
||||
await cacheService.initialize();
|
||||
await chatService.initialize();
|
||||
await avatarService.initialize();
|
||||
|
||||
final cacheStats = await cacheService.getCacheStats();
|
||||
final avatarStats = await avatarService.getAvatarCacheStats();
|
||||
final chatStats = await chatService.getChatCacheStats();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_cacheStats = cacheStats;
|
||||
_avatarCacheStats = avatarStats;
|
||||
_chatCacheStats = chatStats;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Ошибка загрузки статистики кэша: $e'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearAllCache() 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('Отмена'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||
),
|
||||
child: const Text('Очистить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
try {
|
||||
final cacheService = CacheService();
|
||||
final avatarService = AvatarCacheService();
|
||||
final chatService = ChatCacheService();
|
||||
|
||||
await cacheService.initialize();
|
||||
await chatService.initialize();
|
||||
await avatarService.initialize();
|
||||
|
||||
await cacheService.clear();
|
||||
// Небольшая задержка между операциями очистки
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
await avatarService.clearAvatarCache();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
await chatService.clearAllChatCache();
|
||||
|
||||
await _loadCacheStats();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Весь кэш очищен'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Ошибка очистки кэша: $e'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearAvatarCache() 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('Отмена'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Очистить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
try {
|
||||
final avatarService = AvatarCacheService();
|
||||
|
||||
await avatarService.initialize();
|
||||
await avatarService.clearAvatarCache();
|
||||
// Небольшая задержка перед загрузкой статистики
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
await _loadCacheStats();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Кэш аватарок очищен'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Ошибка очистки кэша аватарок: $e'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildStatCard(
|
||||
String title,
|
||||
String value,
|
||||
IconData icon,
|
||||
Color color,
|
||||
) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(icon, color: color, size: 32),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCacheSection(String title, Map<String, dynamic>? data) {
|
||||
if (data == null || data.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...data.entries.map(
|
||||
(entry) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(entry.key),
|
||||
Text(
|
||||
entry.value.toString(),
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("Управление кэшем"),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _loadCacheStats,
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text(
|
||||
"Общая статистика",
|
||||
style: TextStyle(
|
||||
color: colors.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
GridView.count(
|
||||
crossAxisCount: 2,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
childAspectRatio: 1.2,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
children: [
|
||||
_buildStatCard(
|
||||
"Чаты в кэше",
|
||||
_chatCacheStats['cachedChats']?.toString() ?? "0",
|
||||
Icons.chat,
|
||||
colors.primary,
|
||||
),
|
||||
_buildStatCard(
|
||||
"Контакты в кэше",
|
||||
_chatCacheStats['cachedContacts']?.toString() ?? "0",
|
||||
Icons.contacts,
|
||||
colors.secondary,
|
||||
),
|
||||
_buildStatCard(
|
||||
"Аватарки в памяти",
|
||||
_avatarCacheStats['memoryImages']?.toString() ?? "0",
|
||||
Icons.person,
|
||||
colors.tertiary,
|
||||
),
|
||||
_buildStatCard(
|
||||
"Размер кэша",
|
||||
"${_avatarCacheStats['diskSizeMB'] ?? "0"} МБ",
|
||||
Icons.storage,
|
||||
colors.error,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Text(
|
||||
"Детальная статистика",
|
||||
style: TextStyle(
|
||||
color: colors.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
_buildCacheSection("Кэш чатов", _chatCacheStats['cacheStats']),
|
||||
const SizedBox(height: 12),
|
||||
_buildCacheSection("Кэш аватарок", _avatarCacheStats),
|
||||
const SizedBox(height: 12),
|
||||
_buildCacheSection("Общий кэш", _cacheStats),
|
||||
const SizedBox(height: 12),
|
||||
_buildCacheSection("Кэш в памяти", {
|
||||
'Записей в памяти': _cacheStats['memoryEntries'] ?? 0,
|
||||
'Максимум записей': _cacheStats['maxMemorySize'] ?? 0,
|
||||
}),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
Text(
|
||||
"Управление кэшем",
|
||||
style: TextStyle(
|
||||
color: colors.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Card(
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_sweep),
|
||||
title: const Text("Очистить кэш аватарок"),
|
||||
subtitle: const Text(
|
||||
"Удалить все кэшированные аватарки",
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
onTap: _clearAvatarCache,
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.delete_forever,
|
||||
color: colors.error,
|
||||
),
|
||||
title: Text(
|
||||
"Очистить весь кэш",
|
||||
style: TextStyle(color: colors.error),
|
||||
),
|
||||
subtitle: const Text("Удалить все кэшированные данные"),
|
||||
trailing: Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: colors.error,
|
||||
),
|
||||
onTap: _clearAllCache,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: colors.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
"О кэшировании",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
"Кэширование ускоряет работу приложения, сохраняя часто используемые данные локально. "
|
||||
"Все файлы сжимаются с помощью LZ4 для экономии места. "
|
||||
"Чаты кэшируются на 1 час, контакты на 6 часов, сообщения на 2 часа, аватарки на 7 дней.",
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
"Очистка кэша может замедлить работу приложения до повторной загрузки данных.",
|
||||
style: TextStyle(fontStyle: FontStyle.italic),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.compress,
|
||||
color: colors.primary,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
"Сжатие LZ4 включено - экономия места до 70%",
|
||||
style: TextStyle(
|
||||
color: colors.primary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
342
lib/screens/channels_list_screen.dart
Normal file
342
lib/screens/channels_list_screen.dart
Normal file
@@ -0,0 +1,342 @@
|
||||
|
||||
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gwid/api/api_service.dart';
|
||||
import 'package:gwid/models/channel.dart';
|
||||
import 'package:gwid/screens/search_channels_screen.dart';
|
||||
|
||||
class ChannelsListScreen extends StatefulWidget {
|
||||
const ChannelsListScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ChannelsListScreen> createState() => _ChannelsListScreenState();
|
||||
}
|
||||
|
||||
class _ChannelsListScreenState extends State<ChannelsListScreen> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
StreamSubscription? _apiSubscription;
|
||||
bool _isLoading = false;
|
||||
List<Channel> _channels = [];
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_listenToApiMessages();
|
||||
_loadPopularChannels();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_apiSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _listenToApiMessages() {
|
||||
_apiSubscription = ApiService.instance.messages.listen((message) {
|
||||
if (!mounted) return;
|
||||
|
||||
|
||||
if (message['type'] == 'channels_found') {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
final payload = message['payload'];
|
||||
final channelsData = payload['contacts'] as List<dynamic>?;
|
||||
|
||||
if (channelsData != null) {
|
||||
_channels = channelsData
|
||||
.map((channelJson) => Channel.fromJson(channelJson))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (message['type'] == 'channels_not_found') {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_channels.clear();
|
||||
});
|
||||
|
||||
final payload = message['payload'];
|
||||
String errorMessage = 'Каналы не найдены';
|
||||
|
||||
if (payload != null) {
|
||||
if (payload['localizedMessage'] != null) {
|
||||
errorMessage = payload['localizedMessage'];
|
||||
} else if (payload['message'] != null) {
|
||||
errorMessage = payload['message'];
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_errorMessage = errorMessage;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _loadPopularChannels() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
await ApiService.instance.searchChannels('каналы');
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Ошибка загрузки каналов: ${e.toString()}'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _searchChannels() async {
|
||||
final searchQuery = _searchController.text.trim();
|
||||
|
||||
if (searchQuery.isEmpty) {
|
||||
_loadPopularChannels();
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
await ApiService.instance.searchChannels(searchQuery);
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Ошибка поиска каналов: ${e.toString()}'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _viewChannel(Channel channel) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ChannelDetailsScreen(channel: channel),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Каналы'),
|
||||
backgroundColor: colors.surface,
|
||||
foregroundColor: colors.onSurface,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const SearchChannelsScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Поиск каналов...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_loadPopularChannels();
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _searchChannels(),
|
||||
onChanged: (value) {
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _channels.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.broadcast_on_personal,
|
||||
size: 64,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage ?? 'Каналы не найдены',
|
||||
style: TextStyle(
|
||||
color: colors.onSurfaceVariant,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadPopularChannels,
|
||||
child: const Text('Обновить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: _channels.length,
|
||||
itemBuilder: (context, index) {
|
||||
final channel = _channels[index];
|
||||
return _buildChannelCard(channel);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChannelCard(Channel channel) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
leading: CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundImage: channel.photoBaseUrl != null
|
||||
? NetworkImage(channel.photoBaseUrl!)
|
||||
: null,
|
||||
child: channel.photoBaseUrl == null
|
||||
? Text(
|
||||
channel.name.isNotEmpty ? channel.name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
color: colors.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
title: Text(
|
||||
channel.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (channel.description?.isNotEmpty == true) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
channel.description!,
|
||||
style: TextStyle(color: colors.onSurfaceVariant, fontSize: 14),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
if (channel.options.contains('BOT'))
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'Бот',
|
||||
style: TextStyle(
|
||||
color: colors.onPrimaryContainer,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (channel.options.contains('HAS_WEBAPP'))
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'Веб-приложение',
|
||||
style: TextStyle(
|
||||
color: colors.onSecondaryContainer,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 16,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
onTap: () => _viewChannel(channel),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
5512
lib/screens/chat_screen.dart
Normal file
5512
lib/screens/chat_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
4939
lib/screens/chats_screen.dart
Normal file
4939
lib/screens/chats_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
317
lib/screens/custom_request_screen.dart
Normal file
317
lib/screens/custom_request_screen.dart
Normal file
@@ -0,0 +1,317 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:gwid/api/api_service.dart';
|
||||
|
||||
|
||||
class RequestHistoryItem {
|
||||
final String request;
|
||||
final String response;
|
||||
final DateTime timestamp;
|
||||
|
||||
RequestHistoryItem({
|
||||
required this.request,
|
||||
required this.response,
|
||||
required this.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
class CustomRequestScreen extends StatefulWidget {
|
||||
const CustomRequestScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CustomRequestScreen> createState() => _CustomRequestScreenState();
|
||||
}
|
||||
|
||||
class _CustomRequestScreenState extends State<CustomRequestScreen> {
|
||||
final _requestController = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
|
||||
String? _response;
|
||||
String? _error;
|
||||
bool _isLoading = false;
|
||||
|
||||
final List<RequestHistoryItem> _history = [];
|
||||
|
||||
void _handleResponse(Map<String, dynamic> message, String originalRequest) {
|
||||
const encoder = JsonEncoder.withIndent(' ');
|
||||
final formattedResponse = encoder.convert(message);
|
||||
|
||||
if (!mounted) return; // Убедимся, что виджет все еще существует
|
||||
|
||||
setState(() {
|
||||
_response = formattedResponse;
|
||||
_isLoading = false;
|
||||
_error = null;
|
||||
|
||||
|
||||
_history.insert(
|
||||
0,
|
||||
RequestHistoryItem(
|
||||
request: originalRequest, // Используем переданный запрос
|
||||
response: formattedResponse,
|
||||
timestamp: DateTime.now(),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _sendRequest() async {
|
||||
if (_isLoading) return;
|
||||
FocusScope.of(context).unfocus();
|
||||
|
||||
final requestText = _requestController.text.isEmpty
|
||||
? '{}'
|
||||
: _requestController.text;
|
||||
Map<String, dynamic> requestJson;
|
||||
|
||||
try {
|
||||
requestJson = jsonDecode(requestText) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'Ошибка: Невалидный JSON в запросе.\n$e';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_response = null;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
StreamSubscription? subscription;
|
||||
Timer? timeoutTimer;
|
||||
|
||||
try {
|
||||
final int sentSeq = ApiService.instance.sendAndTrackFullJsonRequest(
|
||||
jsonEncode(requestJson),
|
||||
);
|
||||
|
||||
|
||||
timeoutTimer = Timer(const Duration(seconds: 15), () {
|
||||
subscription?.cancel(); // Прекращаем слушать стрим
|
||||
if (mounted && _isLoading) {
|
||||
setState(() {
|
||||
_error = 'Ошибка: Превышено время ожидания ответа (15с).';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
subscription = ApiService.instance.messages.listen((message) {
|
||||
|
||||
if (message['seq'] == sentSeq) {
|
||||
timeoutTimer?.cancel(); // Отменяем таймер
|
||||
subscription?.cancel(); // Отменяем подписку
|
||||
_handleResponse(message, requestText); // Обрабатываем ответ
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
timeoutTimer?.cancel();
|
||||
subscription?.cancel();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = 'Ошибка при отправке запроса: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _useHistoryItem(RequestHistoryItem item) {
|
||||
|
||||
_requestController.text = item.request;
|
||||
setState(() {
|
||||
_response = item.response;
|
||||
_error = null;
|
||||
});
|
||||
_scrollController.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Custom WebSocket Request')),
|
||||
body: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildRequestSection(),
|
||||
const SizedBox(height: 24),
|
||||
_buildResponseSection(),
|
||||
const SizedBox(height: 24),
|
||||
if (_history.isNotEmpty) _buildHistoryWidget(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRequestSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Запрос к серверу', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _requestController,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
alignLabelWithHint: true,
|
||||
hintText: 'Введите полный JSON запроса...',
|
||||
),
|
||||
keyboardType: TextInputType.multiline,
|
||||
maxLines: 12,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 14.0),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: _isLoading ? null : _sendRequest,
|
||||
icon: _isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.send),
|
||||
label: Text(_isLoading ? 'Ожидание...' : 'Отправить запрос'),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
minimumSize: const Size(double.infinity, 50),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResponseSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Ответ от сервера',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
if (_response != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy_all_outlined),
|
||||
tooltip: 'Скопировать ответ',
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: _response!));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Ответ скопирован в буфер обмена'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
constraints: const BoxConstraints(minHeight: 150),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
child: _buildResponseContent(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResponseContent() {
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_error != null) {
|
||||
return SelectableText(
|
||||
_error!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_response != null) {
|
||||
return SelectableText(
|
||||
_response!,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 14),
|
||||
);
|
||||
}
|
||||
return Center(
|
||||
child: Text(
|
||||
'Здесь появится ответ от сервера...',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHistoryWidget() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('История запросов', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 12),
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: _history.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _history[index];
|
||||
String opcode = 'N/A';
|
||||
try {
|
||||
final decoded = jsonDecode(item.request);
|
||||
opcode = decoded['opcode']?.toString() ?? 'N/A';
|
||||
} catch (_) {}
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(child: Text(opcode)),
|
||||
title: Text(
|
||||
'Request: ${item.request.replaceAll('\n', ' ').substring(0, (item.request.length > 50) ? 50 : item.request.length)}...',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${item.timestamp.hour.toString().padLeft(2, '0')}:${item.timestamp.minute.toString().padLeft(2, '0')}:${item.timestamp.second.toString().padLeft(2, '0')}',
|
||||
),
|
||||
onTap: () => _useHistoryItem(item),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_requestController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
740
lib/screens/debug_screen.dart
Normal file
740
lib/screens/debug_screen.dart
Normal file
@@ -0,0 +1,740 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:gwid/screens/cache_management_screen.dart'; // Добавлен импорт
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:gwid/utils/theme_provider.dart';
|
||||
import 'package:gwid/api/api_service.dart';
|
||||
import 'package:gwid/screens/phone_entry_screen.dart';
|
||||
import 'package:gwid/screens/custom_request_screen.dart';
|
||||
import 'dart:async';
|
||||
|
||||
class DebugScreen extends StatelessWidget {
|
||||
const DebugScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final theme = context.watch<ThemeProvider>();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Debug Settings'),
|
||||
backgroundColor: colors.surface,
|
||||
foregroundColor: colors.onSurface,
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_OutlinedSection(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Text(
|
||||
"Performance Debug",
|
||||
style: TextStyle(
|
||||
color: colors.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.speed),
|
||||
title: const Text("Показать FPS overlay"),
|
||||
subtitle: const Text("Отображение FPS и производительности"),
|
||||
trailing: Switch(
|
||||
value: theme.debugShowPerformanceOverlay,
|
||||
onChanged: (value) =>
|
||||
theme.setDebugShowPerformanceOverlay(value),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.refresh),
|
||||
title: const Text("Показать панель обновления чатов"),
|
||||
subtitle: const Text(
|
||||
"Debug панель для обновления списка чатов",
|
||||
),
|
||||
trailing: Switch(
|
||||
value: theme.debugShowChatsRefreshPanel,
|
||||
onChanged: (value) =>
|
||||
theme.setDebugShowChatsRefreshPanel(value),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.message),
|
||||
title: const Text("Показать счётчик сообщений"),
|
||||
subtitle: const Text("Отладочная информация о сообщениях"),
|
||||
trailing: Switch(
|
||||
value: theme.debugShowMessageCount,
|
||||
onChanged: (value) => theme.setDebugShowMessageCount(value),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_OutlinedSection(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Text(
|
||||
"Инструменты разработчика",
|
||||
style: TextStyle(
|
||||
color: colors.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.code),
|
||||
title: const Text("Custom API Request"),
|
||||
subtitle: const Text("Отправить сырой запрос на сервер"),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const CustomRequestScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_OutlinedSection(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Text(
|
||||
"Data Management",
|
||||
style: TextStyle(
|
||||
color: colors.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.delete_forever),
|
||||
title: const Text("Очистить все данные"),
|
||||
subtitle: const Text("Полная очистка кэшей и данных"),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showClearAllDataDialog(context),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.phone),
|
||||
title: const Text("Показать ввод номера"),
|
||||
subtitle: const Text("Открыть экран ввода номера без выхода"),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showPhoneEntryScreen(context),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.traffic),
|
||||
title: const Text("Статистика трафика"),
|
||||
subtitle: const Text("Просмотр использованного трафика"),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showTrafficStats(context),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.storage),
|
||||
title: const Text("Использование памяти"),
|
||||
subtitle: const Text("Просмотр статистики памяти"),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showMemoryUsage(context),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.cached),
|
||||
title: const Text("Управление кэшем"),
|
||||
subtitle: const Text("Настройки кэширования и статистика"),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const CacheManagementScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showClearAllDataDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Очистить все данные'),
|
||||
content: const Text(
|
||||
'Это действие удалит ВСЕ данные приложения:\n\n'
|
||||
'• Все кэши и сообщения\n'
|
||||
'• Настройки и профиль\n'
|
||||
'• Токен авторизации\n'
|
||||
'• История чатов\n\n'
|
||||
'После очистки приложение будет закрыто.\n'
|
||||
'Вы уверены?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
await _performFullDataClear(context);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('Очистить и закрыть'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _performFullDataClear(BuildContext context) async {
|
||||
try {
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const AlertDialog(
|
||||
content: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(width: 16),
|
||||
Text('Очистка данных...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
await ApiService.instance.clearAllData();
|
||||
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Все данные очищены. Приложение будет закрыто.'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
|
||||
|
||||
if (context.mounted) {
|
||||
SystemNavigator.pop();
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Ошибка при очистке данных: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showPhoneEntryScreen(BuildContext context) {
|
||||
Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (context) => const PhoneEntryScreen()));
|
||||
}
|
||||
|
||||
void _showTrafficStats(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Статистика трафика'),
|
||||
content: const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('📊 Статистика использования данных:'),
|
||||
SizedBox(height: 16),
|
||||
Text('• Отправлено сообщений: 1,247'),
|
||||
Text('• Получено сообщений: 3,891'),
|
||||
Text('• Загружено фото: 156 MB'),
|
||||
Text('• Загружено видео: 89 MB'),
|
||||
Text('• Общий трафик: 2.1 GB'),
|
||||
SizedBox(height: 16),
|
||||
Text('📅 За последние 30 дней'),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Закрыть'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showMemoryUsage(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Использование памяти'),
|
||||
content: const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('💾 Использование памяти:'),
|
||||
SizedBox(height: 16),
|
||||
Text('• Кэш сообщений: 45.2 MB'),
|
||||
Text('• Кэш контактов: 12.8 MB'),
|
||||
Text('• Кэш чатов: 8.3 MB'),
|
||||
Text('• Медиа файлы: 156.7 MB'),
|
||||
Text('• Общее использование: 223.0 MB'),
|
||||
SizedBox(height: 16),
|
||||
Text('📱 Доступно памяти: 2.1 GB'),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Закрыть'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
ApiService.instance.clearAllCaches();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Кэш очищен'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Очистить кэш'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OutlinedSection extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const _OutlinedSection({required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: colors.outline.withOpacity(0.3)),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Session {
|
||||
final String client;
|
||||
final String location;
|
||||
final bool current;
|
||||
final int time;
|
||||
final String info;
|
||||
|
||||
Session({
|
||||
required this.client,
|
||||
required this.location,
|
||||
required this.current,
|
||||
required this.time,
|
||||
required this.info,
|
||||
});
|
||||
|
||||
factory Session.fromJson(Map<String, dynamic> json) {
|
||||
return Session(
|
||||
client: json['client'] ?? '',
|
||||
location: json['location'] ?? '',
|
||||
current: json['current'] ?? false,
|
||||
time: json['time'] ?? 0,
|
||||
info: json['info'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SessionsScreen extends StatefulWidget {
|
||||
const SessionsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SessionsScreen> createState() => _SessionsScreenState();
|
||||
}
|
||||
|
||||
class _SessionsScreenState extends State<SessionsScreen> {
|
||||
List<Session> _sessions = [];
|
||||
bool _isLoading = true;
|
||||
bool _isInitialLoad = true;
|
||||
StreamSubscription? _apiSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_listenToApi();
|
||||
|
||||
}
|
||||
|
||||
void _loadSessions() {
|
||||
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
ApiService.instance.requestSessions();
|
||||
}
|
||||
|
||||
void _terminateAllSessions() 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('Отмена'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||
),
|
||||
child: const Text('Сбросить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ApiService.instance.terminateAllSessions();
|
||||
|
||||
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
if (mounted) {
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_loadSessions();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _listenToApi() {
|
||||
_apiSubscription = ApiService.instance.messages.listen((message) {
|
||||
if (message['opcode'] == 96 && mounted) {
|
||||
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
final payload = message['payload'];
|
||||
if (payload != null && payload['sessions'] != null) {
|
||||
final sessionsList = payload['sessions'] as List;
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_sessions = sessionsList
|
||||
.map((session) => Session.fromJson(session))
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
String _formatTime(int timestamp) {
|
||||
final date = DateTime.fromMillisecondsSinceEpoch(timestamp);
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(date);
|
||||
|
||||
String relativeTime;
|
||||
if (difference.inDays > 0) {
|
||||
relativeTime = '${difference.inDays} дн. назад';
|
||||
} else if (difference.inHours > 0) {
|
||||
relativeTime = '${difference.inHours} ч. назад';
|
||||
} else if (difference.inMinutes > 0) {
|
||||
relativeTime = '${difference.inMinutes} мин. назад';
|
||||
} else {
|
||||
relativeTime = 'Только что';
|
||||
}
|
||||
|
||||
|
||||
final exactTime =
|
||||
'${date.day.toString().padLeft(2, '0')}.${date.month.toString().padLeft(2, '0')}.${date.year} ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
||||
|
||||
return '$relativeTime ($exactTime)';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
|
||||
if (_isInitialLoad && _sessions.isEmpty) {
|
||||
_isInitialLoad = false;
|
||||
_loadSessions();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("Активные сессии"),
|
||||
actions: [
|
||||
IconButton(onPressed: _loadSessions, icon: const Icon(Icons.refresh)),
|
||||
],
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _sessions.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.security,
|
||||
size: 64,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
"Нет активных сессий",
|
||||
style: TextStyle(
|
||||
color: colors.onSurfaceVariant,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
|
||||
if (_sessions.any((s) => !s.current))
|
||||
Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.all(16),
|
||||
child: FilledButton.icon(
|
||||
onPressed: _terminateAllSessions,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: colors.error,
|
||||
foregroundColor: colors.onError,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 16,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.logout, size: 24),
|
||||
label: const Text(
|
||||
"Завершить все сессии кроме текущей",
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _sessions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final session = _sessions[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: session.current
|
||||
? colors.primary
|
||||
: colors.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
session.current
|
||||
? Icons.phone_android
|
||||
: Icons.computer,
|
||||
color: session.current
|
||||
? colors.onPrimary
|
||||
: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
session.current ? "Текущая сессия" : session.client,
|
||||
style: TextStyle(
|
||||
fontWeight: session.current
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
color: session.current
|
||||
? colors.primary
|
||||
: colors.onSurface,
|
||||
),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
session.location,
|
||||
style: TextStyle(
|
||||
color: colors.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
session.info,
|
||||
style: TextStyle(
|
||||
color: colors.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_formatTime(session.time),
|
||||
style: TextStyle(
|
||||
color: colors.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: session.current
|
||||
? Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.primary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
"Активна",
|
||||
style: TextStyle(
|
||||
color: colors.onPrimary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
"Неактивна",
|
||||
style: TextStyle(
|
||||
color: colors.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_apiSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
348
lib/screens/downloads_screen.dart
Normal file
348
lib/screens/downloads_screen.dart
Normal file
@@ -0,0 +1,348 @@
|
||||
import 'dart:io' as io;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:open_file/open_file.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class DownloadsScreen extends StatefulWidget {
|
||||
const DownloadsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DownloadsScreen> createState() => _DownloadsScreenState();
|
||||
}
|
||||
|
||||
class _DownloadsScreenState extends State<DownloadsScreen> {
|
||||
List<io.FileSystemEntity> _files = [];
|
||||
bool _isLoading = true;
|
||||
String? _downloadsPath;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadDownloads();
|
||||
}
|
||||
|
||||
Future<void> _loadDownloads() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
io.Directory? downloadDir;
|
||||
|
||||
if (io.Platform.isAndroid) {
|
||||
downloadDir = await getExternalStorageDirectory();
|
||||
} else if (io.Platform.isIOS) {
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
downloadDir = directory;
|
||||
} else if (io.Platform.isWindows || io.Platform.isLinux) {
|
||||
final homeDir =
|
||||
io.Platform.environment['HOME'] ??
|
||||
io.Platform.environment['USERPROFILE'] ??
|
||||
'';
|
||||
downloadDir = io.Directory('$homeDir/Downloads');
|
||||
} else {
|
||||
downloadDir = await getApplicationDocumentsDirectory();
|
||||
}
|
||||
|
||||
if (downloadDir != null && await downloadDir.exists()) {
|
||||
_downloadsPath = downloadDir.path;
|
||||
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final List<String> downloadedFilePaths =
|
||||
prefs.getStringList('downloaded_files') ?? [];
|
||||
|
||||
|
||||
final files =
|
||||
downloadedFilePaths
|
||||
.map((path) => io.File(path))
|
||||
.where((file) => file.existsSync())
|
||||
.toList()
|
||||
..sort((a, b) {
|
||||
final aStat = a.statSync();
|
||||
final bStat = b.statSync();
|
||||
return bStat.modified.compareTo(aStat.modified);
|
||||
});
|
||||
|
||||
|
||||
final existingPaths = files.map((f) => f.path).toSet();
|
||||
final cleanPaths = downloadedFilePaths
|
||||
.where((path) => existingPaths.contains(path))
|
||||
.toList();
|
||||
await prefs.setStringList('downloaded_files', cleanPaths);
|
||||
|
||||
setState(() {
|
||||
_files = files;
|
||||
_isLoading = false;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String _formatFileSize(int bytes) {
|
||||
if (bytes < 1024) {
|
||||
return '$bytes B';
|
||||
} else if (bytes < 1024 * 1024) {
|
||||
return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
} else if (bytes < 1024 * 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
} else {
|
||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getFileIcon(String fileName) {
|
||||
final extension = fileName.split('.').last.toLowerCase();
|
||||
switch (extension) {
|
||||
case 'pdf':
|
||||
return Icons.picture_as_pdf;
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return Icons.description;
|
||||
case 'xls':
|
||||
case 'xlsx':
|
||||
return Icons.table_chart;
|
||||
case 'txt':
|
||||
return Icons.text_snippet;
|
||||
case 'zip':
|
||||
case 'rar':
|
||||
case '7z':
|
||||
return Icons.archive;
|
||||
case 'mp3':
|
||||
case 'wav':
|
||||
case 'flac':
|
||||
return Icons.audiotrack;
|
||||
case 'mp4':
|
||||
case 'avi':
|
||||
case 'mov':
|
||||
return Icons.video_file;
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'png':
|
||||
case 'gif':
|
||||
return Icons.image;
|
||||
default:
|
||||
return Icons.insert_drive_file;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteFile(io.File file) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Удалить файл?'),
|
||||
content: Text(
|
||||
'Вы уверены, что хотите удалить ${file.path.split('/').last}?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Удалить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
try {
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final List<String> downloadedFilePaths =
|
||||
prefs.getStringList('downloaded_files') ?? [];
|
||||
downloadedFilePaths.remove(file.path);
|
||||
await prefs.setStringList('downloaded_files', downloadedFilePaths);
|
||||
|
||||
|
||||
await file.delete();
|
||||
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Файл удален')));
|
||||
_loadDownloads();
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Ошибка при удалении файла: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Загрузки'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loadDownloads,
|
||||
tooltip: 'Обновить',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _files.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.download_outlined,
|
||||
size: 64,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Нет скачанных файлов',
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
),
|
||||
if (_downloadsPath != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Файлы сохраняются в:\n$_downloadsPath',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
if (_downloadsPath != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
color: isDark ? Colors.grey[850] : Colors.grey[200],
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.folder, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_downloadsPath!,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: _files.length,
|
||||
itemBuilder: (context, index) {
|
||||
final file = _files[index];
|
||||
if (file is! io.File) return const SizedBox.shrink();
|
||||
|
||||
final fileName = file.path
|
||||
.split(io.Platform.pathSeparator)
|
||||
.last;
|
||||
final fileStat = file.statSync();
|
||||
final fileSize = fileStat.size;
|
||||
final modifiedDate = fileStat.modified;
|
||||
|
||||
return ListTile(
|
||||
leading: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.primaryColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
_getFileIcon(fileName),
|
||||
color: theme.primaryColor,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
fileName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(_formatFileSize(fileSize)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
DateFormat(
|
||||
'dd.MM.yyyy HH:mm',
|
||||
).format(modifiedDate),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.open_in_new),
|
||||
title: const Text('Открыть'),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
await OpenFile.open(file.path);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.delete,
|
||||
color: Colors.red,
|
||||
),
|
||||
title: const Text(
|
||||
'Удалить',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_deleteFile(file);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
onTap: () async {
|
||||
await OpenFile.open(file.path);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1252
lib/screens/home_screen.dart
Normal file
1252
lib/screens/home_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
635
lib/screens/join_group_screen.dart
Normal file
635
lib/screens/join_group_screen.dart
Normal file
@@ -0,0 +1,635 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gwid/api/api_service.dart';
|
||||
|
||||
class JoinGroupScreen extends StatefulWidget {
|
||||
const JoinGroupScreen({super.key});
|
||||
|
||||
@override
|
||||
State<JoinGroupScreen> createState() => _JoinGroupScreenState();
|
||||
}
|
||||
|
||||
class _JoinGroupScreenState extends State<JoinGroupScreen> {
|
||||
final TextEditingController _linkController = TextEditingController();
|
||||
StreamSubscription? _apiSubscription;
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_listenToApiMessages();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_linkController.dispose();
|
||||
_apiSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _listenToApiMessages() {
|
||||
_apiSubscription = ApiService.instance.messages.listen((message) {
|
||||
if (!mounted) return;
|
||||
|
||||
if (message['type'] == 'group_join_success') {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
final payload = message['payload'];
|
||||
final chat = payload['chat'];
|
||||
final chatTitle = chat?['title'] ?? 'Группа';
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Успешно присоединились к группе "$chatTitle"!'),
|
||||
backgroundColor: Colors.green,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
// Обработка успешной подписки на канал (opcode 57)
|
||||
if (message['cmd'] == 1 && message['opcode'] == 57) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Успешно подписались на канал!'),
|
||||
backgroundColor: Colors.green,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
// Обработка ошибки подписки на канал (opcode 57)
|
||||
if (message['cmd'] == 3 && message['opcode'] == 57) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
final errorPayload = message['payload'];
|
||||
String errorMessage = 'Неизвестная ошибка';
|
||||
if (errorPayload != null) {
|
||||
if (errorPayload['localizedMessage'] != null) {
|
||||
errorMessage = errorPayload['localizedMessage'];
|
||||
} else if (errorPayload['message'] != null) {
|
||||
errorMessage = errorPayload['message'];
|
||||
}
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(errorMessage),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
String _normalizeLink(String inputLink) {
|
||||
String link = inputLink.trim();
|
||||
|
||||
// Поддержка формата @https://max.ru/...
|
||||
if (link.startsWith('@')) {
|
||||
link = link.substring(1).trim();
|
||||
}
|
||||
|
||||
return link;
|
||||
}
|
||||
|
||||
String _extractJoinLink(String inputLink) {
|
||||
final link = _normalizeLink(inputLink);
|
||||
|
||||
if (link.startsWith('join/')) {
|
||||
print('Ссылка уже в правильном формате: $link');
|
||||
return link;
|
||||
}
|
||||
|
||||
final joinIndex = link.indexOf('join/');
|
||||
if (joinIndex != -1) {
|
||||
final extractedLink = link.substring(joinIndex);
|
||||
print('Извлечена ссылка из полной ссылки: $link -> $extractedLink');
|
||||
return extractedLink;
|
||||
}
|
||||
|
||||
print('Не найдено "join/" в ссылке: $link');
|
||||
return link;
|
||||
}
|
||||
|
||||
bool _isChannelLink(String inputLink) {
|
||||
final link = _normalizeLink(inputLink);
|
||||
|
||||
try {
|
||||
final uri = Uri.parse(link);
|
||||
if (uri.host == 'max.ru' &&
|
||||
uri.pathSegments.isNotEmpty &&
|
||||
uri.pathSegments.first.startsWith('id')) {
|
||||
return true;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void _joinGroup() async {
|
||||
final rawInput = _linkController.text.trim();
|
||||
final inputLink = _normalizeLink(rawInput);
|
||||
|
||||
if (inputLink.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Введите ссылку'),
|
||||
backgroundColor: Colors.orange,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Сначала пытаемся распознать ссылку на канал (https://max.ru/id...)
|
||||
if (_isChannelLink(inputLink)) {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final chatInfo = await ApiService.instance.getChatInfoByLink(inputLink);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
// Показываем диалог подписки на канал
|
||||
_showChannelSubscribeDialog(chatInfo, inputLink);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Ошибка: ${e.toString()}'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Иначе считаем, что это ссылка на группу с "join/"
|
||||
final processedLink = _extractJoinLink(inputLink);
|
||||
|
||||
if (!processedLink.contains('join/')) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text(
|
||||
'Неверный формат ссылки. Для группы ссылка должна содержать "join/"',
|
||||
),
|
||||
backgroundColor: Colors.orange,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
await ApiService.instance.joinGroupByLink(processedLink);
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Ошибка присоединения: ${e.toString()}'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Присоединиться по ссылке'),
|
||||
backgroundColor: colors.surface,
|
||||
foregroundColor: colors.onSurface,
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.primaryContainer.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.link, color: colors.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Присоединение по ссылке',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Введите ссылку на группу или канал, чтобы присоединиться. '
|
||||
'Для групп можно вводить полную (https://max.ru/join/...) '
|
||||
'или короткую (join/...) ссылку, для каналов — ссылку вида '
|
||||
'https://max.ru/idXXXXXXXX.',
|
||||
style: TextStyle(color: colors.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Text(
|
||||
'Ссылка',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextField(
|
||||
controller: _linkController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Ссылка на группу или канал',
|
||||
hintText:
|
||||
'https://max.ru/join/ABC123DEF456GHI789JKL или https://max.ru/id7452017130_gos',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
prefixIcon: const Icon(Icons.link),
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: colors.outline.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: colors.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Формат ссылки:',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colors.primary,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'• Для групп: ссылка должна содержать "join/"\n'
|
||||
'• После "join/" должен идти уникальный идентификатор группы\n'
|
||||
'• Примеры групп:\n'
|
||||
' - https://max.ru/join/ABC123DEF456GHI789JKL\n'
|
||||
' - join/ABC123DEF456GHI789JKL\n'
|
||||
'• Для каналов: ссылка вида https://max.ru/idXXXXXXXX',
|
||||
style: TextStyle(
|
||||
color: colors.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isLoading ? null : _joinGroup,
|
||||
icon: _isLoading
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
colors.onPrimary,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.link),
|
||||
label: Text(
|
||||
_isLoading
|
||||
? 'Присоединение...'
|
||||
: 'Присоединиться по ссылке',
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 50),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_isLoading)
|
||||
Container(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showChannelSubscribeDialog(
|
||||
Map<String, dynamic> chatInfo,
|
||||
String linkToJoin,
|
||||
) {
|
||||
final String title = chatInfo['title'] ?? 'Канал';
|
||||
final String? iconUrl =
|
||||
chatInfo['baseIconUrl'] ?? chatInfo['baseUrl'] ?? chatInfo['iconUrl'];
|
||||
|
||||
int subscribeState = 0;
|
||||
String? errorMessage;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
Widget content;
|
||||
List<Widget> actions = [];
|
||||
|
||||
if (subscribeState == 1) {
|
||||
content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 32),
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Подписка...',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
);
|
||||
actions = [];
|
||||
} else if (subscribeState == 2) {
|
||||
content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 32),
|
||||
const Icon(
|
||||
Icons.check_circle_outline,
|
||||
color: Colors.green,
|
||||
size: 60,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Вы подписались на канал!',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
);
|
||||
actions = [
|
||||
FilledButton(
|
||||
child: const Text('Отлично'),
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
},
|
||||
),
|
||||
];
|
||||
} else if (subscribeState == 3) {
|
||||
content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 32),
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
size: 60,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Ошибка',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
errorMessage ?? 'Не удалось подписаться на канал.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
);
|
||||
actions = [
|
||||
TextButton(
|
||||
child: const Text('Закрыть'),
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
},
|
||||
),
|
||||
];
|
||||
} else {
|
||||
content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (iconUrl != null && iconUrl.isNotEmpty)
|
||||
CircleAvatar(
|
||||
radius: 60,
|
||||
backgroundImage: NetworkImage(iconUrl),
|
||||
onBackgroundImageError: (e, s) {
|
||||
print("Ошибка загрузки аватара канала: $e");
|
||||
},
|
||||
backgroundColor: Colors.grey.shade300,
|
||||
)
|
||||
else
|
||||
CircleAvatar(
|
||||
radius: 60,
|
||||
backgroundColor: Colors.grey.shade300,
|
||||
child: const Icon(
|
||||
Icons.campaign,
|
||||
size: 60,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Вы действительно хотите подписаться на этот канал?',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
actions = [
|
||||
TextButton(
|
||||
child: const Text('Отмена'),
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
},
|
||||
),
|
||||
FilledButton(
|
||||
child: const Text('Подписаться'),
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
subscribeState = 1;
|
||||
});
|
||||
|
||||
try {
|
||||
await ApiService.instance.subscribeToChannel(linkToJoin);
|
||||
|
||||
setState(() {
|
||||
subscribeState = 2;
|
||||
});
|
||||
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (mounted) {
|
||||
Navigator.of(dialogContext).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
subscribeState = 3;
|
||||
errorMessage = e.toString().replaceFirst(
|
||||
"Exception: ",
|
||||
"",
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
title: subscribeState == 0
|
||||
? const Text('Подписаться на канал?')
|
||||
: null,
|
||||
content: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeInOut,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
transitionBuilder:
|
||||
(Widget child, Animation<double> animation) {
|
||||
final slideAnimation =
|
||||
Tween<Offset>(
|
||||
begin: const Offset(0, 0.2),
|
||||
end: Offset.zero,
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeOutQuart,
|
||||
),
|
||||
);
|
||||
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
position: slideAnimation,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
key: ValueKey<int>(subscribeState),
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
actionsAlignment: MainAxisAlignment.center,
|
||||
actions: actions,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
513
lib/screens/manage_account_screen.dart
Normal file
513
lib/screens/manage_account_screen.dart
Normal file
@@ -0,0 +1,513 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:gwid/api/api_service.dart';
|
||||
import 'package:gwid/models/profile.dart';
|
||||
import 'package:gwid/screens/phone_entry_screen.dart';
|
||||
import 'package:gwid/services/profile_cache_service.dart';
|
||||
import 'package:gwid/services/local_profile_manager.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'dart:io';
|
||||
|
||||
class ManageAccountScreen extends StatefulWidget {
|
||||
final Profile? myProfile;
|
||||
const ManageAccountScreen({super.key, this.myProfile});
|
||||
|
||||
@override
|
||||
State<ManageAccountScreen> createState() => _ManageAccountScreenState();
|
||||
}
|
||||
|
||||
class _ManageAccountScreenState extends State<ManageAccountScreen> {
|
||||
late final TextEditingController _firstNameController;
|
||||
late final TextEditingController _lastNameController;
|
||||
late final TextEditingController _descriptionController;
|
||||
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||||
final ProfileCacheService _profileCache = ProfileCacheService();
|
||||
final LocalProfileManager _profileManager = LocalProfileManager();
|
||||
|
||||
Profile? _actualProfile;
|
||||
String? _localAvatarPath;
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeProfileData();
|
||||
}
|
||||
|
||||
Future<void> _initializeProfileData() async {
|
||||
await _profileManager.initialize();
|
||||
|
||||
_actualProfile = await _profileManager.getActualProfile(widget.myProfile);
|
||||
|
||||
_firstNameController = TextEditingController(
|
||||
text: _actualProfile?.firstName ?? '',
|
||||
);
|
||||
_lastNameController = TextEditingController(
|
||||
text: _actualProfile?.lastName ?? '',
|
||||
);
|
||||
_descriptionController = TextEditingController(
|
||||
text: _actualProfile?.description ?? '',
|
||||
);
|
||||
final localPath = await _profileManager.getLocalAvatarPath();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_localAvatarPath = localPath;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveProfile() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final firstName = _firstNameController.text.trim();
|
||||
final lastName = _lastNameController.text.trim();
|
||||
final description = _descriptionController.text.trim();
|
||||
|
||||
final userId = _actualProfile?.id ?? widget.myProfile?.id ?? 0;
|
||||
final photoBaseUrl =
|
||||
_actualProfile?.photoBaseUrl ?? widget.myProfile?.photoBaseUrl;
|
||||
final photoId = _actualProfile?.photoId ?? widget.myProfile?.photoId ?? 0;
|
||||
|
||||
await _profileCache.saveProfileData(
|
||||
userId: userId,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
description: description.isEmpty ? null : description,
|
||||
photoBaseUrl: photoBaseUrl,
|
||||
photoId: photoId,
|
||||
);
|
||||
|
||||
_actualProfile = await _profileManager.getActualProfile(widget.myProfile);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Профиль сохранен локально"),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text("Ошибка сохранения: $e"),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _logout() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Выйти из аккаунта?'),
|
||||
content: const Text('Вы уверены, что хотите выйти из аккаунта?'),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.red.shade400,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('Выйти'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
try {
|
||||
await ApiService.instance.logout();
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (context) => const PhoneEntryScreen()),
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Ошибка выхода: $e'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickAndUpdateProfilePhoto() async {
|
||||
try {
|
||||
final ImagePicker picker = ImagePicker();
|
||||
final XFile? image = await picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
maxWidth: 1024,
|
||||
maxHeight: 1024,
|
||||
imageQuality: 85,
|
||||
);
|
||||
|
||||
if (image == null) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
File imageFile = File(image.path);
|
||||
|
||||
final userId = _actualProfile?.id ?? widget.myProfile?.id ?? 0;
|
||||
if (userId != 0) {
|
||||
final localPath = await _profileCache.saveAvatar(imageFile, userId);
|
||||
|
||||
if (localPath != null && mounted) {
|
||||
setState(() {
|
||||
_localAvatarPath = localPath;
|
||||
});
|
||||
_actualProfile = await _profileManager.getActualProfile(
|
||||
widget.myProfile,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Фотография профиля сохранена"),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text("Ошибка загрузки фото: $e"),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("Изменить профиль"),
|
||||
centerTitle: true,
|
||||
scrolledUnderElevation: 0,
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _saveProfile,
|
||||
child: const Text(
|
||||
"Сохранить",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 20.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildAvatarSection(theme),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"Основная информация",
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
TextFormField(
|
||||
controller: _firstNameController,
|
||||
maxLength: 60, // Ограничение по символам
|
||||
decoration: _buildInputDecoration(
|
||||
"Имя",
|
||||
Icons.person_outline,
|
||||
).copyWith(counterText: ""), // Скрываем счетчик
|
||||
validator: (value) =>
|
||||
value!.isEmpty ? 'Введите ваше имя' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _lastNameController,
|
||||
maxLength: 60, // Ограничение по символам
|
||||
decoration: _buildInputDecoration(
|
||||
"Фамилия",
|
||||
Icons.person_outline,
|
||||
).copyWith(counterText: ""), // Скрываем счетчик
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"Дополнительно",
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
TextFormField(
|
||||
controller: _descriptionController,
|
||||
maxLines: 4,
|
||||
maxLength: 400,
|
||||
decoration: _buildInputDecoration(
|
||||
"О себе",
|
||||
Icons.edit_note_outlined,
|
||||
alignLabel: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
if (widget.myProfile != null)
|
||||
Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildInfoTile(
|
||||
icon: Icons.phone_outlined,
|
||||
title: "Телефон",
|
||||
subtitle: widget.myProfile!.formattedPhone,
|
||||
),
|
||||
const Divider(height: 1),
|
||||
_buildTappableInfoTile(
|
||||
icon: Icons.tag,
|
||||
title: "Ваш ID",
|
||||
subtitle: widget.myProfile!.id.toString(),
|
||||
onTap: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(
|
||||
text: widget.myProfile!.id.toString(),
|
||||
),
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('ID скопирован в буфер обмена'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
_buildLogoutButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatarSection(ThemeData theme) {
|
||||
ImageProvider? avatarImage;
|
||||
|
||||
if (_localAvatarPath != null) {
|
||||
avatarImage = FileImage(File(_localAvatarPath!));
|
||||
} else if (_actualProfile?.photoBaseUrl != null) {
|
||||
if (_actualProfile!.photoBaseUrl!.startsWith('file://')) {
|
||||
final path = _actualProfile!.photoBaseUrl!.replaceFirst('file://', '');
|
||||
avatarImage = FileImage(File(path));
|
||||
} else {
|
||||
avatarImage = NetworkImage(_actualProfile!.photoBaseUrl!);
|
||||
}
|
||||
} else if (widget.myProfile?.photoBaseUrl != null) {
|
||||
avatarImage = NetworkImage(widget.myProfile!.photoBaseUrl!);
|
||||
}
|
||||
|
||||
return Center(
|
||||
child: GestureDetector(
|
||||
onTap: _pickAndUpdateProfilePhoto,
|
||||
child: Stack(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 60,
|
||||
backgroundColor: theme.colorScheme.secondaryContainer,
|
||||
backgroundImage: avatarImage,
|
||||
child: avatarImage == null
|
||||
? Icon(
|
||||
Icons.person,
|
||||
size: 60,
|
||||
color: theme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
if (_isLoading)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 3,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 4,
|
||||
right: 4,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Icon(Icons.camera_alt, color: Colors.white, size: 20),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
InputDecoration _buildInputDecoration(
|
||||
String label,
|
||||
IconData icon, {
|
||||
bool alignLabel = false,
|
||||
}) {
|
||||
final prefixIcon = (label == "О себе")
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(bottom: 60), // Смещаем иконку вверх
|
||||
child: Icon(icon),
|
||||
)
|
||||
: Icon(icon);
|
||||
|
||||
return InputDecoration(
|
||||
labelText: label,
|
||||
prefixIcon: prefixIcon,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
alignLabelWithHint: alignLabel,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoTile({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
}) {
|
||||
return ListTile(
|
||||
leading: Icon(icon, color: Theme.of(context).colorScheme.primary),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
subtitle: Text(subtitle),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTappableInfoTile({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: ListTile(
|
||||
leading: Icon(icon, color: Theme.of(context).colorScheme.primary),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
subtitle: Text(subtitle),
|
||||
trailing: const Icon(Icons.copy_outlined, size: 20),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLogoutButton() {
|
||||
return OutlinedButton.icon(
|
||||
icon: const Icon(Icons.logout),
|
||||
label: const Text('Выйти из аккаунта'),
|
||||
onPressed: _logout,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.red.shade400,
|
||||
side: BorderSide(color: Colors.red.shade200),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_firstNameController.dispose();
|
||||
_lastNameController.dispose();
|
||||
_descriptionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
198
lib/screens/otp_screen.dart
Normal file
198
lib/screens/otp_screen.dart
Normal file
@@ -0,0 +1,198 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:pinput/pinput.dart';
|
||||
import 'package:gwid/api/api_service.dart';
|
||||
import 'package:gwid/screens/chats_screen.dart';
|
||||
import 'package:gwid/screens/password_auth_screen.dart';
|
||||
|
||||
class OTPScreen extends StatefulWidget {
|
||||
final String phoneNumber;
|
||||
final String otpToken;
|
||||
|
||||
const OTPScreen({
|
||||
super.key,
|
||||
required this.phoneNumber,
|
||||
required this.otpToken,
|
||||
});
|
||||
|
||||
@override
|
||||
State<OTPScreen> createState() => _OTPScreenState();
|
||||
}
|
||||
|
||||
class _OTPScreenState extends State<OTPScreen> {
|
||||
final TextEditingController _pinController = TextEditingController();
|
||||
final FocusNode _pinFocusNode = FocusNode();
|
||||
StreamSubscription? _apiSubscription;
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_apiSubscription = ApiService.instance.messages.listen((message) {
|
||||
|
||||
if (message['type'] == 'password_required' && mounted) {
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const PasswordAuthScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message['opcode'] == 18 && mounted) {
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
});
|
||||
|
||||
final payload = message['payload'];
|
||||
print('Полный payload при авторизации: $payload');
|
||||
if (payload != null &&
|
||||
payload['tokenAttrs']?['LOGIN']?['token'] != null) {
|
||||
final String finalToken = payload['tokenAttrs']['LOGIN']['token'];
|
||||
final userId = payload['tokenAttrs']?['LOGIN']?['userId'];
|
||||
print('Успешная авторизация! Токен: $finalToken, UserID: $userId');
|
||||
|
||||
ApiService.instance
|
||||
.saveToken(finalToken, userId: userId?.toString())
|
||||
.then((_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Код верный! Вход выполнен.'),
|
||||
backgroundColor: Colors.green,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (context) => const ChatsScreen()),
|
||||
(route) => false,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
_handleIncorrectCode();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _verifyCode(String code) async {
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = true);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await ApiService.instance.verifyCode(widget.otpToken, code);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Ошибка подключения: ${e.toString()}'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handleIncorrectCode() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Неверный код. Попробуйте снова.'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
_pinController.clear();
|
||||
_pinFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final defaultPinTheme = PinTheme(
|
||||
width: 56,
|
||||
height: 60,
|
||||
textStyle: TextStyle(fontSize: 22, color: colors.onSurface),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Подтверждение')),
|
||||
body: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Код отправлен на номер',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.phoneNumber,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
Pinput(
|
||||
length: 6,
|
||||
controller: _pinController,
|
||||
focusNode: _pinFocusNode,
|
||||
autofocus: true,
|
||||
defaultPinTheme: defaultPinTheme,
|
||||
focusedPinTheme: defaultPinTheme.copyWith(
|
||||
decoration: defaultPinTheme.decoration!.copyWith(
|
||||
border: Border.all(color: colors.primary, width: 2),
|
||||
),
|
||||
),
|
||||
onCompleted: (pin) => _verifyCode(pin),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isLoading)
|
||||
Container(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pinController.dispose();
|
||||
_pinFocusNode.dispose();
|
||||
_apiSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
298
lib/screens/password_auth_screen.dart
Normal file
298
lib/screens/password_auth_screen.dart
Normal file
@@ -0,0 +1,298 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gwid/api/api_service.dart';
|
||||
import 'package:gwid/screens/chats_screen.dart';
|
||||
|
||||
class PasswordAuthScreen extends StatefulWidget {
|
||||
const PasswordAuthScreen({super.key});
|
||||
|
||||
@override
|
||||
State<PasswordAuthScreen> createState() => _PasswordAuthScreenState();
|
||||
}
|
||||
|
||||
class _PasswordAuthScreenState extends State<PasswordAuthScreen> {
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
StreamSubscription? _apiSubscription;
|
||||
bool _isLoading = false;
|
||||
String? _hint;
|
||||
String? _email;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
|
||||
_apiSubscription = ApiService.instance.messages.listen((message) {
|
||||
if (message['type'] == 'password_required' && mounted) {
|
||||
setState(() {
|
||||
_hint = message['hint'];
|
||||
_email = message['email'];
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (message['opcode'] == 115 && message['cmd'] == 1 && mounted) {
|
||||
final payload = message['payload'];
|
||||
if (payload != null &&
|
||||
payload['tokenAttrs']?['LOGIN']?['token'] != null) {
|
||||
final String finalToken = payload['tokenAttrs']['LOGIN']['token'];
|
||||
final userId = payload['tokenAttrs']?['LOGIN']?['userId'];
|
||||
|
||||
print(
|
||||
'Успешная аутентификация паролем! Токен: $finalToken, UserID: $userId',
|
||||
);
|
||||
|
||||
|
||||
ApiService.instance
|
||||
.saveToken(finalToken, userId: userId?.toString())
|
||||
.then((_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Пароль верный! Вход выполнен.'),
|
||||
backgroundColor: Colors.green,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
ApiService.instance.clearPasswordAuthData();
|
||||
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (context) => const ChatsScreen()),
|
||||
(route) => false,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (message['opcode'] == 115 && message['cmd'] == 3 && mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
final error = message['payload'];
|
||||
String errorMessage = 'Ошибка аутентификации';
|
||||
|
||||
if (error != null) {
|
||||
if (error['localizedMessage'] != null) {
|
||||
errorMessage = error['localizedMessage'];
|
||||
} else if (error['message'] != null) {
|
||||
errorMessage = error['message'];
|
||||
}
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(errorMessage),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
final authData = ApiService.instance.getPasswordAuthData();
|
||||
_hint = authData['hint'];
|
||||
_email = authData['email'];
|
||||
}
|
||||
|
||||
void _submitPassword() async {
|
||||
final password = _passwordController.text.trim();
|
||||
if (password.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Введите пароль'),
|
||||
backgroundColor: Colors.orange,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final authData = ApiService.instance.getPasswordAuthData();
|
||||
final trackId = authData['trackId'];
|
||||
|
||||
if (trackId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Ошибка: отсутствует идентификатор сессии'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
await ApiService.instance.sendPassword(trackId, password);
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Ошибка отправки пароля: ${e.toString()}'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Ввод пароля'),
|
||||
backgroundColor: colors.surface,
|
||||
elevation: 0,
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
|
||||
if (_email != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: colors.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'Аккаунт защищен паролем',
|
||||
style: TextStyle(
|
||||
color: colors.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_email!,
|
||||
style: TextStyle(
|
||||
color: colors.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
if (_hint != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Подсказка: $_hint',
|
||||
style: TextStyle(
|
||||
color: colors.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 30),
|
||||
|
||||
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Пароль',
|
||||
hintText: 'Введите пароль от аккаунта',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
prefixIcon: const Icon(Icons.lock),
|
||||
filled: true,
|
||||
fillColor: colors.surfaceContainerHighest,
|
||||
),
|
||||
onSubmitted: (_) => _submitPassword(),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: FilledButton(
|
||||
onPressed: _isLoading ? null : _submitPassword,
|
||||
style: FilledButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Colors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Text('Войти'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
if (_isLoading)
|
||||
Container(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_passwordController.dispose();
|
||||
_apiSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
418
lib/screens/password_management_screen.dart
Normal file
418
lib/screens/password_management_screen.dart
Normal file
@@ -0,0 +1,418 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:gwid/api/api_service.dart';
|
||||
|
||||
class PasswordManagementScreen extends StatefulWidget {
|
||||
const PasswordManagementScreen({super.key});
|
||||
|
||||
@override
|
||||
State<PasswordManagementScreen> createState() =>
|
||||
_PasswordManagementScreenState();
|
||||
}
|
||||
|
||||
class _PasswordManagementScreenState extends State<PasswordManagementScreen> {
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
final TextEditingController _confirmPasswordController =
|
||||
TextEditingController();
|
||||
final TextEditingController _hintController = TextEditingController();
|
||||
|
||||
StreamSubscription? _apiSubscription;
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_listenToApiMessages();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_passwordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
_hintController.dispose();
|
||||
_apiSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _listenToApiMessages() {
|
||||
_apiSubscription = ApiService.instance.messages.listen((message) {
|
||||
if (!mounted) return;
|
||||
|
||||
if (message['type'] == 'password_set_success') {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
_clearFields();
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Пароль успешно установлен!'),
|
||||
backgroundColor: Colors.green,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (message['cmd'] == 3 && message['opcode'] == 116) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
final errorPayload = message['payload'];
|
||||
String errorMessage = 'Неизвестная ошибка';
|
||||
if (errorPayload != null) {
|
||||
if (errorPayload['localizedMessage'] != null) {
|
||||
errorMessage = errorPayload['localizedMessage'];
|
||||
} else if (errorPayload['message'] != null) {
|
||||
errorMessage = errorPayload['message'];
|
||||
}
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(errorMessage),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _clearFields() {
|
||||
_passwordController.clear();
|
||||
_confirmPasswordController.clear();
|
||||
_hintController.clear();
|
||||
}
|
||||
|
||||
void _setPassword() async {
|
||||
final password = _passwordController.text.trim();
|
||||
final confirmPassword = _confirmPasswordController.text.trim();
|
||||
final hint = _hintController.text.trim();
|
||||
|
||||
if (password.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Введите пароль'),
|
||||
backgroundColor: Colors.orange,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Пароль должен содержать минимум 6 символов'),
|
||||
backgroundColor: Colors.orange,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length > 30) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Пароль не должен превышать 30 символов'),
|
||||
backgroundColor: Colors.orange,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password.contains(RegExp(r'[A-Z]')) ||
|
||||
!password.contains(RegExp(r'[a-z]'))) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text(
|
||||
'Пароль должен содержать заглавные и строчные буквы',
|
||||
),
|
||||
backgroundColor: Colors.orange,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password.contains(RegExp(r'[0-9]'))) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Пароль должен содержать цифры'),
|
||||
backgroundColor: Colors.orange,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]'))) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text(
|
||||
'Пароль должен содержать специальные символы (!@#\$%^&*)',
|
||||
),
|
||||
backgroundColor: Colors.orange,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (password != confirmPassword) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Пароли не совпадают'),
|
||||
backgroundColor: Colors.orange,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
await ApiService.instance.setAccountPassword(password, hint);
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Ошибка установки пароля: ${e.toString()}'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Пароль аккаунта')),
|
||||
body: Stack(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.primaryContainer.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: colors.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Пароль аккаунта',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Пароль добавляет дополнительную защиту к вашему аккаунту. '
|
||||
'После установки пароля для входа потребуется не только SMS-код, '
|
||||
'но и пароль.',
|
||||
style: TextStyle(color: colors.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Text(
|
||||
'Установить пароль',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Новый пароль',
|
||||
hintText: 'Введите пароль',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
prefixIcon: const Icon(Icons.lock),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextField(
|
||||
controller: _confirmPasswordController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Подтвердите пароль',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextField(
|
||||
controller: _hintController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Подсказка для пароля (необязательно)',
|
||||
hintText: 'Например: "Мой любимый цвет"',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
prefixIcon: const Icon(Icons.lightbulb_outline),
|
||||
),
|
||||
maxLength: 30,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: colors.outline.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: colors.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Требования к паролю:',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colors.primary,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'• Не менее 6 символов\n'
|
||||
'• Содержать заглавные и строчные буквы\n'
|
||||
'• Включать цифры и специальные символы (!@#\$%^&*)\n'
|
||||
'• Максимум 30 символов',
|
||||
style: TextStyle(
|
||||
color: colors.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isLoading ? null : _setPassword,
|
||||
icon: _isLoading
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
colors.onPrimary,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.lock),
|
||||
label: Text(
|
||||
_isLoading ? 'Установка...' : 'Установить пароль',
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 50),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_isLoading)
|
||||
Container(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
758
lib/screens/phone_entry_screen.dart
Normal file
758
lib/screens/phone_entry_screen.dart
Normal file
@@ -0,0 +1,758 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:gwid/api/api_service.dart';
|
||||
import 'package:gwid/screens/otp_screen.dart';
|
||||
import 'package:gwid/utils/proxy_service.dart';
|
||||
import 'package:gwid/screens/settings/auth_settings_screen.dart';
|
||||
import 'package:gwid/screens/token_auth_screen.dart';
|
||||
import 'package:gwid/screens/tos_screen.dart'; // Импорт экрана ToS
|
||||
import 'package:mask_text_input_formatter/mask_text_input_formatter.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class Country {
|
||||
final String name;
|
||||
final String code;
|
||||
final String flag;
|
||||
final String mask;
|
||||
final int digits;
|
||||
|
||||
const Country({
|
||||
required this.name,
|
||||
required this.code,
|
||||
required this.flag,
|
||||
required this.mask,
|
||||
required this.digits,
|
||||
});
|
||||
}
|
||||
|
||||
class PhoneEntryScreen extends StatefulWidget {
|
||||
const PhoneEntryScreen({super.key});
|
||||
|
||||
@override
|
||||
State<PhoneEntryScreen> createState() => _PhoneEntryScreenState();
|
||||
}
|
||||
|
||||
class _PhoneEntryScreenState extends State<PhoneEntryScreen>
|
||||
with TickerProviderStateMixin {
|
||||
final TextEditingController _phoneController = TextEditingController();
|
||||
|
||||
static const List<Country> _countries = [
|
||||
Country(
|
||||
name: 'Россия',
|
||||
code: '+7',
|
||||
flag: '🇷🇺',
|
||||
mask: '+7 (###) ###-##-##',
|
||||
digits: 10,
|
||||
),
|
||||
Country(
|
||||
name: 'Беларусь',
|
||||
code: '+375',
|
||||
flag: '🇧🇾',
|
||||
mask: '+375 (##) ###-##-##',
|
||||
digits: 9,
|
||||
),
|
||||
];
|
||||
|
||||
Country _selectedCountry = _countries[0];
|
||||
late MaskTextInputFormatter _maskFormatter;
|
||||
bool _isButtonEnabled = false;
|
||||
bool _isLoading = false;
|
||||
bool _hasCustomAnonymity = false;
|
||||
bool _hasProxyConfigured = false;
|
||||
StreamSubscription? _apiSubscription;
|
||||
bool _showContent = false;
|
||||
bool _isTosAccepted = false; // Состояние для отслеживания принятия соглашения
|
||||
|
||||
late final AnimationController _animationController;
|
||||
late final Animation<Alignment> _topAlignmentAnimation;
|
||||
late final Animation<Alignment> _bottomAlignmentAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 15),
|
||||
);
|
||||
|
||||
_topAlignmentAnimation =
|
||||
AlignmentTween(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.topRight,
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeInOut,
|
||||
),
|
||||
);
|
||||
_bottomAlignmentAnimation =
|
||||
AlignmentTween(
|
||||
begin: Alignment.bottomRight,
|
||||
end: Alignment.bottomLeft,
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeInOut,
|
||||
),
|
||||
);
|
||||
|
||||
_animationController.repeat(reverse: true);
|
||||
|
||||
_initializeMaskFormatter();
|
||||
_checkAnonymitySettings();
|
||||
_checkProxySettings();
|
||||
_phoneController.addListener(_onPhoneChanged);
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
if (mounted) setState(() => _showContent = true);
|
||||
});
|
||||
|
||||
_apiSubscription = ApiService.instance.messages.listen((message) {
|
||||
if (message['opcode'] == 17 && mounted) {
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
});
|
||||
final payload = message['payload'];
|
||||
if (payload != null && payload['token'] != null) {
|
||||
final String token = payload['token'];
|
||||
final String fullPhoneNumber =
|
||||
_selectedCountry.code + _maskFormatter.getUnmaskedText();
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
OTPScreen(phoneNumber: fullPhoneNumber, otpToken: token),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Не удалось запросить код. Попробуйте позже.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _initializeMaskFormatter() {
|
||||
final mask = _selectedCountry.mask
|
||||
.replaceFirst(RegExp(r'^\+\d+\s?'), '')
|
||||
.trim();
|
||||
_maskFormatter = MaskTextInputFormatter(
|
||||
mask: mask,
|
||||
filter: {"#": RegExp(r'[0-9]')},
|
||||
type: MaskAutoCompletionType.lazy,
|
||||
);
|
||||
}
|
||||
|
||||
void _onPhoneChanged() {
|
||||
final text = _phoneController.text;
|
||||
if (text.isNotEmpty) {
|
||||
Country? detectedCountry = _detectCountryFromInput(text);
|
||||
if (detectedCountry != null && detectedCountry != _selectedCountry) {
|
||||
if (_shouldClearFieldForCountry(text, detectedCountry)) {
|
||||
_phoneController.clear();
|
||||
}
|
||||
setState(() {
|
||||
_selectedCountry = detectedCountry;
|
||||
_initializeMaskFormatter();
|
||||
});
|
||||
}
|
||||
}
|
||||
final isFull =
|
||||
_maskFormatter.getUnmaskedText().length == _selectedCountry.digits;
|
||||
if (isFull != _isButtonEnabled) {
|
||||
setState(() => _isButtonEnabled = isFull);
|
||||
}
|
||||
}
|
||||
|
||||
bool _shouldClearFieldForCountry(String input, Country country) {
|
||||
final cleanInput = input.replaceAll(RegExp(r'[^\d+]'), '');
|
||||
if (country.code == '+7') {
|
||||
return !(cleanInput.startsWith('+7') || cleanInput.startsWith('7'));
|
||||
} else if (country.code == '+375') {
|
||||
return !(cleanInput.startsWith('+375') || cleanInput.startsWith('375'));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Country? _detectCountryFromInput(String input) {
|
||||
final cleanInput = input.replaceAll(RegExp(r'[^\d+]'), '');
|
||||
if (cleanInput.startsWith('+7') || cleanInput.startsWith('7')) {
|
||||
return _countries.firstWhere((c) => c.code == '+7');
|
||||
} else if (cleanInput.startsWith('+375') || cleanInput.startsWith('375')) {
|
||||
return _countries.firstWhere((c) => c.code == '+375');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _onCountryChanged(Country? country) {
|
||||
if (country != null && country != _selectedCountry) {
|
||||
setState(() {
|
||||
_selectedCountry = country;
|
||||
_phoneController.clear();
|
||||
_initializeMaskFormatter();
|
||||
_isButtonEnabled = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _checkAnonymitySettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final anonymityEnabled = prefs.getBool('anonymity_enabled') ?? false;
|
||||
if (mounted) setState(() => _hasCustomAnonymity = anonymityEnabled);
|
||||
}
|
||||
|
||||
Future<void> _checkProxySettings() async {
|
||||
final settings = await ProxyService.instance.loadProxySettings();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_hasProxyConfigured = settings.isEnabled && settings.host.isNotEmpty;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _requestOtp() async {
|
||||
if (!_isButtonEnabled || _isLoading || !_isTosAccepted) return;
|
||||
setState(() => _isLoading = true);
|
||||
final String fullPhoneNumber =
|
||||
_selectedCountry.code + _maskFormatter.getUnmaskedText();
|
||||
try {
|
||||
ApiService.instance.errorStream.listen((error) {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
_showErrorDialog(error);
|
||||
}
|
||||
});
|
||||
await ApiService.instance.requestOtp(fullPhoneNumber);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
_showErrorDialog('Ошибка подключения: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showErrorDialog(String error) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) => AlertDialog(
|
||||
title: const Text('Ошибка валидации'),
|
||||
content: Text(error),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: _topAlignmentAnimation.value,
|
||||
end: _bottomAlignmentAnimation.value,
|
||||
colors: [
|
||||
Color.lerp(colors.surface, colors.primary, 0.2)!,
|
||||
Color.lerp(colors.surface, colors.tertiary, 0.15)!,
|
||||
colors.surface,
|
||||
Color.lerp(colors.surface, colors.secondary, 0.15)!,
|
||||
Color.lerp(colors.surface, colors.primary, 0.25)!,
|
||||
],
|
||||
stops: const [0.0, 0.25, 0.5, 0.75, 1.0],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 340),
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 700),
|
||||
curve: Curves.easeOut,
|
||||
opacity: _showContent ? 1.0 : 0.0,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 48),
|
||||
Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: colors.primary.withOpacity(0.1),
|
||||
),
|
||||
child: const Image(
|
||||
image: AssetImage(
|
||||
'assets/images/komet_512.png',
|
||||
),
|
||||
width: 75,
|
||||
height: 75,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Komet',
|
||||
textAlign: TextAlign.center,
|
||||
style: GoogleFonts.manrope(
|
||||
textStyle: textTheme.headlineLarge,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Введите номер телефона для входа',
|
||||
textAlign: TextAlign.center,
|
||||
style: GoogleFonts.manrope(
|
||||
textStyle: textTheme.titleMedium,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
_PhoneInput(
|
||||
phoneController: _phoneController,
|
||||
maskFormatter: _maskFormatter,
|
||||
selectedCountry: _selectedCountry,
|
||||
countries: _countries,
|
||||
onCountryChanged: _onCountryChanged,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _isTosAccepted,
|
||||
onChanged: (bool? value) {
|
||||
setState(() {
|
||||
_isTosAccepted = value ?? false;
|
||||
});
|
||||
},
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
style: GoogleFonts.manrope(
|
||||
textStyle: textTheme.bodySmall,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
children: [
|
||||
const TextSpan(text: 'Я принимаю '),
|
||||
TextSpan(
|
||||
text: 'Пользовательское соглашение',
|
||||
style: TextStyle(
|
||||
color: colors.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: colors.primary,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
const TosScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: _isButtonEnabled && _isTosAccepted
|
||||
? _requestOtp
|
||||
: null,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
child: Text(
|
||||
'Далее',
|
||||
style: GoogleFonts.manrope(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _isTosAccepted
|
||||
? () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
const TokenAuthScreen(),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
icon: const Icon(Icons.vpn_key_outlined),
|
||||
label: Text(
|
||||
'Альтернативные способы входа',
|
||||
style: GoogleFonts.manrope(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
_SettingsButton(
|
||||
hasCustomAnonymity: _hasCustomAnonymity,
|
||||
hasProxyConfigured: _hasProxyConfigured,
|
||||
onRefresh: () {
|
||||
_checkAnonymitySettings();
|
||||
_checkProxySettings();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text.rich(
|
||||
textAlign: TextAlign.center,
|
||||
TextSpan(
|
||||
style: GoogleFonts.manrope(
|
||||
textStyle: textTheme.bodySmall,
|
||||
color: colors.onSurfaceVariant.withOpacity(0.8),
|
||||
),
|
||||
children: [
|
||||
const TextSpan(
|
||||
text:
|
||||
'Используя Komet, вы принимаете на себя всю ответственность за использование стороннего клиента.\n',
|
||||
),
|
||||
TextSpan(
|
||||
text: '@TeamKomet',
|
||||
style: TextStyle(
|
||||
color: colors.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: colors.primary,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () async {
|
||||
final Uri url = Uri.parse(
|
||||
'https://t.me/TeamKomet',
|
||||
);
|
||||
if (!await launchUrl(url)) {
|
||||
debugPrint('Could not launch $url');
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isLoading)
|
||||
Container(
|
||||
color: colors.scrim.withOpacity(0.7),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
colors.onPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Отправляем код...',
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colors.onPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
_phoneController.dispose();
|
||||
_apiSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _PhoneInput extends StatelessWidget {
|
||||
final TextEditingController phoneController;
|
||||
final MaskTextInputFormatter maskFormatter;
|
||||
final Country selectedCountry;
|
||||
final List<Country> countries;
|
||||
final ValueChanged<Country?> onCountryChanged;
|
||||
|
||||
const _PhoneInput({
|
||||
required this.phoneController,
|
||||
required this.maskFormatter,
|
||||
required this.selectedCountry,
|
||||
required this.countries,
|
||||
required this.onCountryChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
controller: phoneController,
|
||||
inputFormatters: [maskFormatter],
|
||||
keyboardType: TextInputType.number,
|
||||
style: GoogleFonts.manrope(
|
||||
textStyle: Theme.of(context).textTheme.titleMedium,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: maskFormatter.getMask()?.replaceAll('#', '0'),
|
||||
prefixIcon: _CountryPicker(
|
||||
selectedCountry: selectedCountry,
|
||||
countries: countries,
|
||||
onCountryChanged: onCountryChanged,
|
||||
),
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
),
|
||||
),
|
||||
autofocus: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CountryPicker extends StatelessWidget {
|
||||
final Country selectedCountry;
|
||||
final List<Country> countries;
|
||||
final ValueChanged<Country?> onCountryChanged;
|
||||
|
||||
const _CountryPicker({
|
||||
required this.selectedCountry,
|
||||
required this.countries,
|
||||
required this.onCountryChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(left: 8),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<Country>(
|
||||
value: selectedCountry,
|
||||
onChanged: onCountryChanged,
|
||||
icon: Icon(Icons.keyboard_arrow_down, color: colors.onSurfaceVariant),
|
||||
items: countries.map((Country country) {
|
||||
return DropdownMenuItem<Country>(
|
||||
value: country,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(country.flag, style: textTheme.titleMedium),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
country.code,
|
||||
style: GoogleFonts.manrope(
|
||||
textStyle: textTheme.titleMedium,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsButton extends StatelessWidget {
|
||||
final bool hasCustomAnonymity;
|
||||
final bool hasProxyConfigured;
|
||||
final VoidCallback onRefresh;
|
||||
|
||||
const _SettingsButton({
|
||||
required this.hasCustomAnonymity,
|
||||
required this.hasProxyConfigured,
|
||||
required this.onRefresh,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
final hasAnySettings = hasCustomAnonymity || hasProxyConfigured;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: hasAnySettings
|
||||
? colors.primaryContainer.withOpacity(0.3)
|
||||
: colors.surfaceContainerHighest.withOpacity(0.5),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: hasAnySettings
|
||||
? colors.primary.withOpacity(0.3)
|
||||
: colors.outline.withOpacity(0.3),
|
||||
width: hasAnySettings ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const AuthSettingsScreen()),
|
||||
);
|
||||
onRefresh();
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: hasAnySettings
|
||||
? colors.primary.withOpacity(0.15)
|
||||
: colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.tune_outlined,
|
||||
color: hasAnySettings
|
||||
? colors.primary
|
||||
: colors.onSurfaceVariant,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Настройки',
|
||||
style: GoogleFonts.manrope(
|
||||
textStyle: textTheme.titleMedium,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
hasAnySettings
|
||||
? 'Настроены дополнительные параметры'
|
||||
: 'Прокси и анонимность',
|
||||
style: GoogleFonts.manrope(
|
||||
textStyle: textTheme.bodySmall,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
color: colors.onSurfaceVariant,
|
||||
size: 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (hasAnySettings) ...[
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (hasCustomAnonymity) ...[
|
||||
Icon(
|
||||
Icons.verified_user,
|
||||
size: 16,
|
||||
color: colors.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Анонимность',
|
||||
style: GoogleFonts.manrope(
|
||||
textStyle: textTheme.labelSmall,
|
||||
color: colors.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (hasCustomAnonymity && hasProxyConfigured) ...[
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
width: 4,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
if (hasProxyConfigured) ...[
|
||||
Icon(Icons.vpn_key, size: 16, color: colors.primary),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Прокси',
|
||||
style: GoogleFonts.manrope(
|
||||
textStyle: textTheme.labelSmall,
|
||||
color: colors.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
304
lib/screens/profile_menu_dialog.dart
Normal file
304
lib/screens/profile_menu_dialog.dart
Normal file
@@ -0,0 +1,304 @@
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gwid/screens/manage_account_screen.dart';
|
||||
import 'package:gwid/models/profile.dart';
|
||||
import 'package:gwid/screens/settings/settings_screen.dart';
|
||||
import 'package:gwid/screens/phone_entry_screen.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:gwid/utils/theme_provider.dart';
|
||||
|
||||
class ProfileMenuDialog extends StatefulWidget {
|
||||
final Profile? myProfile;
|
||||
|
||||
const ProfileMenuDialog({super.key, this.myProfile});
|
||||
|
||||
@override
|
||||
State<ProfileMenuDialog> createState() => _ProfileMenuDialogState();
|
||||
}
|
||||
|
||||
class _ProfileMenuDialogState extends State<ProfileMenuDialog> {
|
||||
bool _isAvatarExpanded = false;
|
||||
|
||||
void _toggleAvatar() {
|
||||
setState(() {
|
||||
_isAvatarExpanded = !_isAvatarExpanded;
|
||||
});
|
||||
}
|
||||
|
||||
void _collapseAvatar() {
|
||||
if (_isAvatarExpanded) {
|
||||
setState(() {
|
||||
_isAvatarExpanded = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
final String subtitle = "Профиль";
|
||||
|
||||
final Profile? myProfile = widget.myProfile;
|
||||
|
||||
return Dialog(
|
||||
alignment: Alignment.topCenter,
|
||||
insetPadding: const EdgeInsets.only(top: 60.0, left: 16.0, right: 16.0),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28.0)),
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Stack(
|
||||
children: [
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 12.0,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
"Komet",
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0x0ff33333),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: GestureDetector(
|
||||
onTap: _toggleAvatar,
|
||||
child: Opacity(
|
||||
opacity: _isAvatarExpanded ? 0 : 1,
|
||||
child: CircleAvatar(
|
||||
radius: 22,
|
||||
backgroundImage:
|
||||
myProfile?.photoBaseUrl != null
|
||||
? NetworkImage(myProfile!.photoBaseUrl!)
|
||||
: null,
|
||||
child: myProfile?.photoBaseUrl == null
|
||||
? Text(
|
||||
myProfile?.displayName.isNotEmpty ==
|
||||
true
|
||||
? myProfile!.displayName[0]
|
||||
.toUpperCase()
|
||||
: '?',
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
myProfile?.displayName ?? "Загрузка...",
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
subtitle: Text(subtitle),
|
||||
trailing: const Icon(
|
||||
Icons.keyboard_arrow_down_rounded,
|
||||
),
|
||||
),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final extra = context
|
||||
.read<ThemeProvider>()
|
||||
.extraTransition;
|
||||
final strength = context
|
||||
.read<ThemeProvider>()
|
||||
.extraAnimationStrength;
|
||||
final panel = SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
shape: const StadiumBorder(),
|
||||
side: BorderSide(color: colors.outline),
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
ManageAccountScreen(
|
||||
myProfile: myProfile,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text("Управление аккаунтом"),
|
||||
),
|
||||
);
|
||||
if (extra == TransitionOption.slide &&
|
||||
_isAvatarExpanded) {
|
||||
return AnimatedSlide(
|
||||
offset: _isAvatarExpanded
|
||||
? Offset.zero
|
||||
: Offset(0, strength / 400.0),
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeInOut,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _isAvatarExpanded ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeInOut,
|
||||
child: panel,
|
||||
),
|
||||
);
|
||||
}
|
||||
return panel;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Divider(),
|
||||
_SettingsTile(
|
||||
icon: Icons.settings_outlined,
|
||||
title: "Настройки",
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const SettingsScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_SettingsTile(
|
||||
icon: Icons.logout,
|
||||
title: "Выйти",
|
||||
onTap: () async {
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const PhoneEntryScreen(),
|
||||
),
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
if (_isAvatarExpanded)
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _collapseAvatar,
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
AnimatedAlign(
|
||||
alignment: _isAvatarExpanded
|
||||
? Alignment.center
|
||||
: Alignment.topLeft,
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeInOut,
|
||||
child: IgnorePointer(
|
||||
ignoring: !_isAvatarExpanded,
|
||||
child: GestureDetector(
|
||||
onTap: () {},
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final extra = context
|
||||
.read<ThemeProvider>()
|
||||
.extraTransition;
|
||||
final avatar = CircleAvatar(
|
||||
radius: 80,
|
||||
backgroundImage: widget.myProfile?.photoBaseUrl != null
|
||||
? NetworkImage(widget.myProfile!.photoBaseUrl!)
|
||||
: null,
|
||||
child: widget.myProfile?.photoBaseUrl == null
|
||||
? Text(
|
||||
widget.myProfile?.displayName.isNotEmpty == true
|
||||
? widget.myProfile!.displayName[0]
|
||||
.toUpperCase()
|
||||
: '?',
|
||||
style: const TextStyle(fontSize: 36),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
if (extra == TransitionOption.slide) {
|
||||
return AnimatedSlide(
|
||||
offset: _isAvatarExpanded
|
||||
? Offset.zero
|
||||
: const Offset(0, -1),
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeInOut,
|
||||
child: avatar,
|
||||
);
|
||||
}
|
||||
return AnimatedScale(
|
||||
scale: _isAvatarExpanded ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeInOut,
|
||||
child: avatar,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _SettingsTile({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(icon),
|
||||
title: Text(title),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
913
lib/screens/search_channels_screen.dart
Normal file
913
lib/screens/search_channels_screen.dart
Normal file
@@ -0,0 +1,913 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gwid/api/api_service.dart';
|
||||
import 'package:gwid/models/channel.dart';
|
||||
|
||||
class SearchChannelsScreen extends StatefulWidget {
|
||||
const SearchChannelsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SearchChannelsScreen> createState() => _SearchChannelsScreenState();
|
||||
}
|
||||
|
||||
class _SearchChannelsScreenState extends State<SearchChannelsScreen> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
StreamSubscription? _apiSubscription;
|
||||
bool _isLoading = false;
|
||||
List<Channel> _foundChannels = [];
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_listenToApiMessages();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_apiSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _listenToApiMessages() {
|
||||
_apiSubscription = ApiService.instance.messages.listen((message) {
|
||||
if (!mounted) return;
|
||||
|
||||
if (message['type'] == 'channels_found') {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
final payload = message['payload'];
|
||||
final channelsData = payload['contacts'] as List<dynamic>?;
|
||||
|
||||
if (channelsData != null) {
|
||||
_foundChannels = channelsData
|
||||
.map((channelJson) => Channel.fromJson(channelJson))
|
||||
.toList();
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Найдено каналов: ${_foundChannels.length}'),
|
||||
backgroundColor: Colors.green,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (message['type'] == 'channels_not_found') {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_foundChannels.clear();
|
||||
});
|
||||
|
||||
final payload = message['payload'];
|
||||
String errorMessage = 'Каналы не найдены';
|
||||
|
||||
if (payload != null) {
|
||||
if (payload['localizedMessage'] != null) {
|
||||
errorMessage = payload['localizedMessage'];
|
||||
} else if (payload['message'] != null) {
|
||||
errorMessage = payload['message'];
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_errorMessage = errorMessage;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(errorMessage),
|
||||
backgroundColor: Colors.orange,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _searchChannels() async {
|
||||
final searchQuery = _searchController.text.trim();
|
||||
|
||||
if (searchQuery.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Введите поисковый запрос'),
|
||||
backgroundColor: Colors.orange,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_foundChannels.clear();
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
await ApiService.instance.searchChannels(searchQuery);
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Ошибка поиска каналов: ${e.toString()}'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _viewChannel(Channel channel) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ChannelDetailsScreen(channel: channel),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Поиск каналов'),
|
||||
backgroundColor: colors.surface,
|
||||
foregroundColor: colors.onSurface,
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.primaryContainer.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.broadcast_on_personal,
|
||||
color: colors.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Поиск каналов',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Найдите интересные каналы по названию или описанию. '
|
||||
'Вы можете просматривать каналы и подписываться на них.',
|
||||
style: TextStyle(color: colors.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Text(
|
||||
'Поисковый запрос',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Название или описание канала',
|
||||
hintText: 'Например: новости, технологии, развлечения',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: colors.outline.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: colors.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Советы по поиску:',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colors.primary,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'• Используйте ключевые слова\n'
|
||||
'• Поиск по названию или описанию\n'
|
||||
'• Попробуйте разные варианты написания',
|
||||
style: TextStyle(
|
||||
color: colors.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isLoading ? null : _searchChannels,
|
||||
icon: _isLoading
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
colors.onPrimary,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.search),
|
||||
label: Text(_isLoading ? 'Поиск...' : 'Найти каналы'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 50),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (_foundChannels.isNotEmpty) ...[
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Найденные каналы (${_foundChannels.length})',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
..._foundChannels.map(
|
||||
(channel) => _buildChannelCard(channel),
|
||||
),
|
||||
],
|
||||
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.orange.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.warning, color: Colors.orange),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
color: Colors.orange.shade800,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_isLoading)
|
||||
Container(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChannelCard(Channel channel) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
leading: CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundImage: channel.photoBaseUrl != null
|
||||
? NetworkImage(channel.photoBaseUrl!)
|
||||
: null,
|
||||
child: channel.photoBaseUrl == null
|
||||
? Text(
|
||||
channel.name.isNotEmpty ? channel.name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
color: colors.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
title: Text(
|
||||
channel.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (channel.description?.isNotEmpty == true) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
channel.description!,
|
||||
style: TextStyle(color: colors.onSurfaceVariant, fontSize: 14),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
if (channel.options.contains('BOT'))
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'Бот',
|
||||
style: TextStyle(
|
||||
color: colors.onPrimaryContainer,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (channel.options.contains('HAS_WEBAPP')) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'Веб-приложение',
|
||||
style: TextStyle(
|
||||
color: colors.onSecondaryContainer,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 16,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
onTap: () => _viewChannel(channel),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChannelDetailsScreen extends StatefulWidget {
|
||||
final Channel channel;
|
||||
|
||||
const ChannelDetailsScreen({super.key, required this.channel});
|
||||
|
||||
@override
|
||||
State<ChannelDetailsScreen> createState() => _ChannelDetailsScreenState();
|
||||
}
|
||||
|
||||
class _ChannelDetailsScreenState extends State<ChannelDetailsScreen> {
|
||||
StreamSubscription? _apiSubscription;
|
||||
bool _isLoading = false;
|
||||
String? _webAppUrl;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_listenToApiMessages();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_apiSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _listenToApiMessages() {
|
||||
_apiSubscription = ApiService.instance.messages.listen((message) {
|
||||
if (!mounted) return;
|
||||
|
||||
if (message['type'] == 'channel_entered') {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
final payload = message['payload'];
|
||||
_webAppUrl = payload['url'] as String?;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Канал открыт'),
|
||||
backgroundColor: Colors.green,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (message['type'] == 'channel_subscribed') {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Подписка на канал успешна!'),
|
||||
backgroundColor: Colors.green,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (message['type'] == 'channel_error') {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
final payload = message['payload'];
|
||||
String errorMessage = 'Произошла ошибка';
|
||||
|
||||
if (payload != null) {
|
||||
if (payload['localizedMessage'] != null) {
|
||||
errorMessage = payload['localizedMessage'];
|
||||
} else if (payload['message'] != null) {
|
||||
errorMessage = payload['message'];
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_errorMessage = errorMessage;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(errorMessage),
|
||||
backgroundColor: Colors.red,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
String _extractChannelLink(String inputLink) {
|
||||
String link = inputLink.trim();
|
||||
|
||||
// Поддержка формата @https://max.ru/...
|
||||
if (link.startsWith('@')) {
|
||||
link = link.substring(1).trim();
|
||||
}
|
||||
|
||||
return link;
|
||||
}
|
||||
|
||||
void _enterChannel() async {
|
||||
if (widget.channel.link == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('У канала нет ссылки для входа'),
|
||||
backgroundColor: Colors.orange,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final processedLink = _extractChannelLink(widget.channel.link!);
|
||||
await ApiService.instance.enterChannel(processedLink);
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Ошибка входа в канал: ${e.toString()}'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _subscribeToChannel() async {
|
||||
if (widget.channel.link == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('У канала нет ссылки для подписки'),
|
||||
backgroundColor: Colors.orange,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final processedLink = _extractChannelLink(widget.channel.link!);
|
||||
await ApiService.instance.subscribeToChannel(processedLink);
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Ошибка подписки на канал: ${e.toString()}'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Канал'),
|
||||
backgroundColor: colors.surface,
|
||||
foregroundColor: colors.onSurface,
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Card(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 40,
|
||||
backgroundImage: widget.channel.photoBaseUrl != null
|
||||
? NetworkImage(widget.channel.photoBaseUrl!)
|
||||
: null,
|
||||
child: widget.channel.photoBaseUrl == null
|
||||
? Text(
|
||||
widget.channel.name.isNotEmpty
|
||||
? widget.channel.name[0].toUpperCase()
|
||||
: '?',
|
||||
style: TextStyle(
|
||||
color: colors.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 24,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
widget.channel.name,
|
||||
style: Theme.of(context).textTheme.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (widget.channel.description?.isNotEmpty == true) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.channel.description!,
|
||||
style: TextStyle(
|
||||
color: colors.onSurfaceVariant,
|
||||
fontSize: 16,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
if (widget.channel.options.contains('BOT'))
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
'Бот',
|
||||
style: TextStyle(
|
||||
color: colors.onPrimaryContainer,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.channel.options.contains('HAS_WEBAPP'))
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
'Веб-приложение',
|
||||
style: TextStyle(
|
||||
color: colors.onSecondaryContainer,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isLoading ? null : _enterChannel,
|
||||
icon: _isLoading
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
colors.onPrimary,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.visibility),
|
||||
label: Text(
|
||||
_isLoading ? 'Загрузка...' : 'Просмотреть канал',
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 50),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _isLoading ? null : _subscribeToChannel,
|
||||
icon: const Icon(Icons.subscriptions),
|
||||
label: const Text('Подписаться на канал'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 50),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (_webAppUrl != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.primaryContainer.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: colors.primary.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.web, color: colors.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Веб-приложение канала',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Канал имеет веб-приложение. Вы можете открыть его в браузере.',
|
||||
style: TextStyle(color: colors.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text(
|
||||
'Открытие веб-приложения...',
|
||||
),
|
||||
backgroundColor: Colors.blue,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.open_in_browser),
|
||||
label: const Text('Открыть веб-приложение'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colors.primary,
|
||||
foregroundColor: colors.onPrimary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.red.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error, color: Colors.red),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
color: Colors.red.shade800,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_isLoading)
|
||||
Container(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
458
lib/screens/search_contact_screen.dart
Normal file
458
lib/screens/search_contact_screen.dart
Normal file
@@ -0,0 +1,458 @@
|
||||
|
||||
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gwid/api/api_service.dart';
|
||||
import 'package:gwid/models/contact.dart';
|
||||
|
||||
class SearchContactScreen extends StatefulWidget {
|
||||
const SearchContactScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SearchContactScreen> createState() => _SearchContactScreenState();
|
||||
}
|
||||
|
||||
class _SearchContactScreenState extends State<SearchContactScreen> {
|
||||
final TextEditingController _phoneController = TextEditingController();
|
||||
StreamSubscription? _apiSubscription;
|
||||
bool _isLoading = false;
|
||||
Contact? _foundContact;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_listenToApiMessages();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneController.dispose();
|
||||
_apiSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _listenToApiMessages() {
|
||||
_apiSubscription = ApiService.instance.messages.listen((message) {
|
||||
if (!mounted) return;
|
||||
|
||||
|
||||
if (message['type'] == 'contact_found') {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
final payload = message['payload'];
|
||||
final contactData = payload['contact'];
|
||||
|
||||
if (contactData != null) {
|
||||
_foundContact = Contact.fromJson(contactData);
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Контакт найден!'),
|
||||
backgroundColor: Colors.green,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (message['type'] == 'contact_not_found') {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_foundContact = null;
|
||||
});
|
||||
|
||||
final payload = message['payload'];
|
||||
String errorMessage = 'Контакт не найден';
|
||||
|
||||
if (payload != null) {
|
||||
if (payload['localizedMessage'] != null) {
|
||||
errorMessage = payload['localizedMessage'];
|
||||
} else if (payload['message'] != null) {
|
||||
errorMessage = payload['message'];
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_errorMessage = errorMessage;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(errorMessage),
|
||||
backgroundColor: Colors.orange,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _searchContact() async {
|
||||
final phone = _phoneController.text.trim();
|
||||
|
||||
if (phone.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Введите номер телефона'),
|
||||
backgroundColor: Colors.orange,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!phone.startsWith('+') || phone.length < 10) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Введите номер телефона в формате +7XXXXXXXXXX'),
|
||||
backgroundColor: Colors.orange,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_foundContact = null;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
await ApiService.instance.searchContactByPhone(phone);
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Ошибка поиска контакта: ${e.toString()}'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _startChat() {
|
||||
if (_foundContact != null) {
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Создание чата с ${_foundContact!.name}'),
|
||||
backgroundColor: Colors.blue,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(10),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Найти контакт'),
|
||||
backgroundColor: colors.surface,
|
||||
foregroundColor: colors.onSurface,
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.primaryContainer.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.person_search, color: colors.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Поиск контакта',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Введите номер телефона для поиска контакта. '
|
||||
'Пользователь должен быть зарегистрирован в системе '
|
||||
'и разрешить поиск по номеру телефона.',
|
||||
style: TextStyle(color: colors.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
|
||||
Text(
|
||||
'Номер телефона',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextField(
|
||||
controller: _phoneController,
|
||||
keyboardType: TextInputType.phone,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Номер телефона',
|
||||
hintText: '+7XXXXXXXXXX',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
prefixIcon: const Icon(Icons.phone),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: colors.outline.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: colors.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Формат номера:',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colors.primary,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'• Номер должен начинаться с "+"\n'
|
||||
'• Пример: +79999999990\n'
|
||||
'• Минимум 10 цифр после "+"',
|
||||
style: TextStyle(
|
||||
color: colors.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isLoading ? null : _searchContact,
|
||||
icon: _isLoading
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
colors.onPrimary,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.search),
|
||||
label: Text(_isLoading ? 'Поиск...' : 'Найти контакт'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 50),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
if (_foundContact != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.green.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: Colors.green),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Контакт найден',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundImage:
|
||||
_foundContact!.photoBaseUrl != null
|
||||
? NetworkImage(_foundContact!.photoBaseUrl!)
|
||||
: null,
|
||||
child: _foundContact!.photoBaseUrl == null
|
||||
? Text(
|
||||
_foundContact!.name.isNotEmpty
|
||||
? _foundContact!.name[0].toUpperCase()
|
||||
: '?',
|
||||
style: TextStyle(
|
||||
color: colors.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_foundContact!.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
if (_foundContact!.description?.isNotEmpty ==
|
||||
true)
|
||||
Text(
|
||||
_foundContact!.description!,
|
||||
style: TextStyle(
|
||||
color: colors.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _startChat,
|
||||
icon: const Icon(Icons.chat),
|
||||
label: const Text('Написать сообщение'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.green,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.orange.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.warning, color: Colors.orange),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
color: Colors.orange.shade800,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_isLoading)
|
||||
Container(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gwid/tos_screen.dart';
|
||||
import 'package:gwid/screens/tos_screen.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class AboutScreen extends StatelessWidget {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:gwid/theme_provider.dart';
|
||||
import 'package:gwid/utils/theme_provider.dart';
|
||||
|
||||
|
||||
class AnimationsScreen extends StatelessWidget {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:gwid/theme_provider.dart';
|
||||
import 'package:gwid/utils/theme_provider.dart';
|
||||
import 'package:gwid/screens/settings/customization_screen.dart';
|
||||
import 'package:gwid/screens/settings/animations_screen.dart';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:gwid/proxy_service.dart';
|
||||
import 'package:gwid/utils/proxy_service.dart';
|
||||
import 'package:gwid/screens/settings/proxy_settings_screen.dart';
|
||||
import 'package:gwid/screens/settings/session_spoofing_screen.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:gwid/theme_provider.dart';
|
||||
import 'package:gwid/utils/theme_provider.dart';
|
||||
|
||||
class BypassScreen extends StatelessWidget {
|
||||
final bool isModal;
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:gwid/theme_provider.dart';
|
||||
import 'package:gwid/utils/theme_provider.dart';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
import 'package:gwid/models/message.dart';
|
||||
|
||||
@@ -4,8 +4,8 @@ import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:gwid/api/api_service.dart';
|
||||
import 'package:gwid/proxy_service.dart';
|
||||
import 'package:gwid/spoofing_service.dart';
|
||||
import 'package:gwid/utils/proxy_service.dart';
|
||||
import 'package:gwid/utils/spoofing_service.dart';
|
||||
import 'package:encrypt/encrypt.dart' as encrypt;
|
||||
import 'package:crypto/crypto.dart' as crypto;
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gwid/api/api_service.dart';
|
||||
import 'package:gwid/theme_provider.dart';
|
||||
import 'package:gwid/utils/theme_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:gwid/password_management_screen.dart';
|
||||
import 'package:gwid/screens/password_management_screen.dart';
|
||||
|
||||
class PrivacySettingsScreen extends StatefulWidget {
|
||||
const PrivacySettingsScreen({super.key});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gwid/proxy_service.dart';
|
||||
import 'package:gwid/proxy_settings.dart';
|
||||
import 'package:gwid/utils/proxy_service.dart';
|
||||
import 'package:gwid/utils/proxy_settings.dart';
|
||||
|
||||
class ProxySettingsScreen extends StatefulWidget {
|
||||
const ProxySettingsScreen({super.key});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gwid/api/api_service.dart';
|
||||
import 'package:gwid/home_screen.dart';
|
||||
import 'package:gwid/screens/home_screen.dart';
|
||||
|
||||
class ReconnectionScreen extends StatefulWidget {
|
||||
const ReconnectionScreen({super.key});
|
||||
|
||||
@@ -8,7 +8,7 @@ import 'package:flutter_timezone/flutter_timezone.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:gwid/api/api_service.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:gwid/device_presets.dart';
|
||||
import 'package:gwid/utils/device_presets.dart';
|
||||
|
||||
enum SpoofingMethod { partial, full }
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gwid/models/profile.dart';
|
||||
import 'package:gwid/api/api_service.dart';
|
||||
import 'package:gwid/manage_account_screen.dart';
|
||||
import 'package:gwid/screens/manage_account_screen.dart';
|
||||
import 'package:gwid/screens/settings/appearance_settings_screen.dart';
|
||||
import 'package:gwid/screens/settings/notification_settings_screen.dart';
|
||||
import 'package:gwid/screens/settings/privacy_security_screen.dart';
|
||||
@@ -9,9 +9,9 @@ import 'package:gwid/screens/settings/storage_screen.dart';
|
||||
import 'package:gwid/screens/settings/network_settings_screen.dart';
|
||||
import 'package:gwid/screens/settings/bypass_screen.dart';
|
||||
import 'package:gwid/screens/settings/about_screen.dart';
|
||||
import 'package:gwid/debug_screen.dart';
|
||||
import 'package:gwid/screens/debug_screen.dart';
|
||||
import 'package:gwid/screens/settings/komet_misc_screen.dart';
|
||||
import 'package:gwid/theme_provider.dart';
|
||||
import 'package:gwid/utils/theme_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
|
||||
437
lib/screens/token_auth_screen.dart
Normal file
437
lib/screens/token_auth_screen.dart
Normal file
@@ -0,0 +1,437 @@
|
||||
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
|
||||
import 'package:gwid/api/api_service.dart';
|
||||
import 'package:gwid/screens/home_screen.dart';
|
||||
import 'package:gwid/utils/proxy_service.dart';
|
||||
import 'package:gwid/utils/proxy_settings.dart';
|
||||
import 'package:gwid/screens/settings/qr_scanner_screen.dart';
|
||||
import 'package:gwid/screens/settings/session_spoofing_screen.dart';
|
||||
|
||||
|
||||
import 'package:encrypt/encrypt.dart' as encrypt;
|
||||
import 'package:crypto/crypto.dart' as crypto;
|
||||
|
||||
class TokenAuthScreen extends StatefulWidget {
|
||||
const TokenAuthScreen({super.key});
|
||||
|
||||
@override
|
||||
State<TokenAuthScreen> createState() => _TokenAuthScreenState();
|
||||
}
|
||||
|
||||
class _TokenAuthScreenState extends State<TokenAuthScreen> {
|
||||
final TextEditingController _tokenController = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tokenController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
Future<void> _processLogin({
|
||||
required String token,
|
||||
Map<String, dynamic>? spoofData,
|
||||
ProxySettings? proxySettings,
|
||||
}) async {
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoading = true);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
||||
try {
|
||||
if (spoofData != null && spoofData.isNotEmpty) {
|
||||
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Настройки анонимности из файла применены!'),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (proxySettings != null) {
|
||||
await ProxyService.instance.saveProxySettings(proxySettings);
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Настройки прокси из файла применены!'),
|
||||
backgroundColor: Colors.blue,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await ApiService.instance.saveToken(token);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (context) => const HomeScreen()),
|
||||
(Route<dynamic> route) => false,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Ошибка входа: ${e.toString()}'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
void _loginWithToken() {
|
||||
final token = _tokenController.text.trim();
|
||||
if (token.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Введите токен для входа')));
|
||||
return;
|
||||
}
|
||||
_processLogin(token: token);
|
||||
}
|
||||
|
||||
Future<void> _loadSessionFile() async {
|
||||
|
||||
FilePickerResult? result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['json','ksession'],
|
||||
);
|
||||
if (result == null || result.files.single.path == null) return;
|
||||
final filePath = result.files.single.path!;
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final fileContent = await File(filePath).readAsString();
|
||||
Map<String, dynamic> jsonData = json.decode(fileContent);
|
||||
String finalJsonPayload;
|
||||
if (jsonData['encrypted'] == true) {
|
||||
final password = await _showPasswordDialog();
|
||||
if (password == null || password.isEmpty) {
|
||||
setState(() => _isLoading = false);
|
||||
return;
|
||||
}
|
||||
final iv = encrypt.IV.fromBase64(jsonData['iv_base64']);
|
||||
final encryptedData = encrypt.Encrypted.fromBase64(
|
||||
jsonData['data_base64'],
|
||||
);
|
||||
final keyBytes = utf8.encode(password);
|
||||
final keyHash = crypto.sha256.convert(keyBytes);
|
||||
final key = encrypt.Key(Uint8List.fromList(keyHash.bytes));
|
||||
final encrypter = encrypt.Encrypter(
|
||||
encrypt.AES(key, mode: encrypt.AESMode.cbc),
|
||||
);
|
||||
finalJsonPayload = encrypter.decrypt(encryptedData, iv: iv);
|
||||
} else {
|
||||
finalJsonPayload = fileContent;
|
||||
}
|
||||
final Map<String, dynamic> sessionData = json.decode(finalJsonPayload);
|
||||
final String? token = sessionData['token'];
|
||||
if (token == null || token.isEmpty)
|
||||
throw Exception('Файл сессии не содержит токена.');
|
||||
await _processLogin(
|
||||
token: token,
|
||||
spoofData: sessionData['spoof_data'] is Map<String, dynamic>
|
||||
? sessionData['spoof_data']
|
||||
: null,
|
||||
proxySettings: sessionData['proxy_settings'] is Map<String, dynamic>
|
||||
? ProxySettings.fromJson(sessionData['proxy_settings'])
|
||||
: null,
|
||||
);
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Ошибка: $e'), backgroundColor: Colors.red),
|
||||
);
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _processQrData(String qrData) async {
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoading = true);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(qrData) as Map<String, dynamic>;
|
||||
|
||||
|
||||
if (decoded['type'] != 'komet_auth_v1' ||
|
||||
decoded['token'] == null ||
|
||||
decoded['timestamp'] == null) {
|
||||
throw Exception("Неверный формат QR-кода.");
|
||||
}
|
||||
|
||||
|
||||
final int qrTimestamp = decoded['timestamp'];
|
||||
final String token = decoded['token'];
|
||||
|
||||
|
||||
final int now = DateTime.now().millisecondsSinceEpoch;
|
||||
const int oneMinuteInMillis = 60 * 1000; // 60 секунд
|
||||
|
||||
if ((now - qrTimestamp) > oneMinuteInMillis) {
|
||||
|
||||
throw Exception("QR-код устарел. Пожалуйста, сгенерируйте новый.");
|
||||
}
|
||||
|
||||
|
||||
await _processLogin(token: token);
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Ошибка: ${e.toString()}'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _showQrSourceSelection() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Wrap(
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.camera_alt_outlined),
|
||||
title: const Text('Камера'),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
_scanWithCamera();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library_outlined),
|
||||
title: const Text('Галерея'),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
_scanFromGallery();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _scanWithCamera() async {
|
||||
final result = await Navigator.of(context).push<String>(
|
||||
MaterialPageRoute(builder: (context) => const QrScannerScreen()),
|
||||
);
|
||||
if (result != null) await _processQrData(result);
|
||||
}
|
||||
|
||||
Future<void> _scanFromGallery() async {
|
||||
final image = await ImagePicker().pickImage(source: ImageSource.gallery);
|
||||
if (image == null) return;
|
||||
final controller = MobileScannerController();
|
||||
final result = await controller.analyzeImage(image.path);
|
||||
await controller.dispose();
|
||||
if (result != null &&
|
||||
result.barcodes.isNotEmpty &&
|
||||
result.barcodes.first.rawValue != null) {
|
||||
await _processQrData(result.barcodes.first.rawValue!);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('QR-код на изображении не найден.'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _showPasswordDialog() {
|
||||
|
||||
final passwordController = TextEditingController();
|
||||
bool isPasswordVisible = false;
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setStateDialog) => AlertDialog(
|
||||
title: const Text('Введите пароль'),
|
||||
content: TextField(
|
||||
controller: passwordController,
|
||||
obscureText: !isPasswordVisible,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Пароль от файла сессии',
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
isPasswordVisible ? Icons.visibility_off : Icons.visibility,
|
||||
),
|
||||
onPressed: () => setStateDialog(
|
||||
() => isPasswordVisible = !isPasswordVisible,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(null),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(passwordController.text),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Другие способы входа')),
|
||||
body: Stack(
|
||||
children: [
|
||||
ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
|
||||
_AuthCard(
|
||||
icon: Icons.qr_code_scanner_rounded,
|
||||
title: 'Вход по QR-коду',
|
||||
subtitle:
|
||||
'Отсканируйте QR-код с другого устройства, чтобы быстро войти.',
|
||||
buttonLabel: 'Сканировать QR-код',
|
||||
onPressed: _showQrSourceSelection,
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
|
||||
_AuthCard(
|
||||
icon: Icons.file_open_outlined,
|
||||
title: 'Вход по файлу сессии',
|
||||
subtitle:
|
||||
'Загрузите ранее экспортированный .json или .ksession файл для восстановления сессии.',
|
||||
buttonLabel: 'Загрузить файл',
|
||||
onPressed: _loadSessionFile,
|
||||
isOutlined: true,
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
|
||||
_AuthCard(
|
||||
icon: Icons.vpn_key_outlined,
|
||||
title: 'Вход по токену',
|
||||
subtitle: 'Введите токен авторизации (AUTH_TOKEN) вручную.',
|
||||
buttonLabel: 'Войти с токеном',
|
||||
onPressed: _loginWithToken,
|
||||
isOutlined: true,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16.0),
|
||||
child: TextField(
|
||||
controller: _tokenController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Токен',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_isLoading)
|
||||
Container(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _AuthCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String buttonLabel;
|
||||
final VoidCallback onPressed;
|
||||
final bool isOutlined;
|
||||
final Widget? child;
|
||||
|
||||
const _AuthCard({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.buttonLabel,
|
||||
required this.onPressed,
|
||||
this.isOutlined = false,
|
||||
this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Card(
|
||||
elevation: isOutlined ? 0 : 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: isOutlined
|
||||
? BorderSide(
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.5),
|
||||
)
|
||||
: BorderSide.none,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 28,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
title,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
subtitle,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (child != null) ...[const SizedBox(height: 20), child!],
|
||||
const SizedBox(height: 20),
|
||||
isOutlined
|
||||
? OutlinedButton(onPressed: onPressed, child: Text(buttonLabel))
|
||||
: FilledButton(onPressed: onPressed, child: Text(buttonLabel)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
176
lib/screens/tos_screen.dart
Normal file
176
lib/screens/tos_screen.dart
Normal file
@@ -0,0 +1,176 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class TosScreen extends StatelessWidget {
|
||||
const TosScreen({super.key});
|
||||
|
||||
|
||||
final String tosText = """
|
||||
### 1. Статус и отношения
|
||||
1.1. «Komet» (далее — «Приложение») — неофициальное стороннее приложение, не имеющее отношения к ООО «Коммуникационная платформа» (правообладатель сервиса «MAX»).
|
||||
|
||||
1.2. Разработчики Приложения не являются партнёрами, сотрудниками или аффилированными лицами ООО «Коммуникационная платформа».
|
||||
|
||||
1.3. Все упоминания торговых марок «MAX» и связанных сервисов принадлежат их правообладателям.
|
||||
|
||||
### 2. Условия использования
|
||||
2.1. Используя Приложение «Komet», вы:
|
||||
- Автоматически подтверждаете согласие с официальным Пользовательским соглашением «MAX» (https://legal.max.ru/ps)
|
||||
- Осознаёте, что использование неофициального клиента может привести к блокировке аккаунта со стороны ООО «Коммуникационная платформа»;
|
||||
- Принимаете на себя все риски, связанные с использованием Приложения.
|
||||
|
||||
2.2. Строго запрещено:
|
||||
- Использовать Приложение «Komet» для распространения запрещённого контента;
|
||||
- Осуществлять массовые рассылки (спам);
|
||||
- Нарушать законодательство РФ и международное право;
|
||||
- Предпринимать попытки взлома или нарушения работы оригинального сервиса «MAX».
|
||||
|
||||
2.3. Техническая реализация соответствует принципу добросовестного использования (fair use) и не нарушает исключительные права правообладателя в соответствии с статьёй 1273 ГК РФ.
|
||||
|
||||
2.4. Особенности технического взаимодействия:
|
||||
- Приложение «Komet» использует публично доступные методы взаимодействия с сервисом «MAX», аналогичные веб-версии (https://web.max.ru)
|
||||
- Все запросы выполняются в рамках добросовестного использования для обеспечения совместимости;
|
||||
- Разработчики не осуществляют обход технических средств защиты и не декомпилируют оригинальное ПО.
|
||||
|
||||
### 3. Технические аспекты
|
||||
3.1. Приложение «Komet» использует только публично доступные методы взаимодействия с сервисом «MAX» через официальные конечные точки.
|
||||
|
||||
3.2. Все запросы выполняются в рамках добросовестного использования (fair use) для обеспечения совместимости.
|
||||
|
||||
3.3. Разработчики не несут ответственности за:
|
||||
- Изменения в API оригинального сервиса;
|
||||
- Блокировку аккаунтов пользователей;
|
||||
- Функциональные ограничения, вызванные действиями ООО «Коммуникационная платформа».
|
||||
|
||||
### 4. Конфиденциальность
|
||||
4.1. Приложение «Komet» не хранит и не обрабатывает персональные данные пользователей.
|
||||
|
||||
4.2. Все данные авторизации передаются напрямую серверам ООО «Коммуникационная платформа».
|
||||
|
||||
4.3. Разработчики не имеют доступа к логинам, паролям, переписке и другим персональным данным пользователей.
|
||||
|
||||
### 5. Ответственность и ограничения
|
||||
5.1. Приложение «Komet» предоставляется «как есть» (as is) без гарантий работоспособности.
|
||||
|
||||
5.2. Разработчики вправе прекратить поддержку Приложения в любой момент без объяснения причин.
|
||||
|
||||
5.3. Пользователь обязуется не использовать Приложение «Komet» в коммерческих целях.
|
||||
|
||||
### 6. Правовые основания
|
||||
6.1. Разработка и распространение Приложения «Komet» осуществляются в соответствии с:
|
||||
- Статья 1280.1 ГК РФ — декомпилирование программы для обеспечения совместимости;
|
||||
- Статья 1229 ГК РФ — ограничения исключительного права в информационных целях;
|
||||
- Федеральный закон № 149-ФЗ «Об информации» — использование общедоступной информации;
|
||||
- Право на межоперабельность (Directive (EU) 2019/790) — обеспечение взаимодействия программ.
|
||||
|
||||
6.2. Взаимодействие с сервисом «MAX» осуществляется исключительно через:
|
||||
- Публичные API-интерфейсы, доступные через веб-версию сервиса;
|
||||
- Методы обратной разработки, разрешённые ст. 1280.1 ГК РФ для целей совместимости;
|
||||
- Открытые протоколы взаимодействия, не защищённые техническими средствами охраны.
|
||||
|
||||
6.3. Приложение «Komet» не обходит технические средства защиты и не нарушает нормальную работу оригинального сервиса, что соответствует требованиям статьи 1299 ГК РФ.
|
||||
|
||||
### 7. Заключительные положения
|
||||
7.1. Используя Приложение «Komet», вы соглашаетесь с тем, что:
|
||||
- Единственным правомочным способом использования сервиса «MAX» является применение официальных клиентов;
|
||||
- Все претензии по работе сервиса должны направляться в ООО «Коммуникационная платформа»;
|
||||
- Разработчики Приложения не несут ответственности за любые косвенные или прямые убытки.
|
||||
|
||||
7.2. Настоящее соглашение может быть изменено без предварительного уведомления пользователей.
|
||||
|
||||
### 8. Функции безопасности и конфиденциальности
|
||||
8.1. Приложение «Komet» включает инструменты защиты приватности:
|
||||
- Подмена данных сессии — для предотвращения отслеживания пользователя;
|
||||
- Система прокси-подключений — для обеспечения безопасности сетевого взаимодействия;
|
||||
- Ограничение телеметрии — для минимизации передачи диагностических данных.
|
||||
|
||||
8.2. Данные функции:
|
||||
- Направлены исключительно на защиту конфиденциальности пользователей;
|
||||
- Не используются для обхода систем безопасности оригинального сервиса;
|
||||
- Реализованы в рамках статьи 152.1 ГК РФ о защите частной жизни.
|
||||
|
||||
8.3. Разработчики не несут ответственности за:
|
||||
- Блокировки, связанные с использованием инструментов конфиденциальности;
|
||||
- Изменения в работе сервиса при активации данных функций.
|
||||
|
||||
### 8.4. Функции экспорта и импорта сессии
|
||||
8.4.1. Приложение «Komet» предоставляет возможность экспорта и импорта данных сессии для:
|
||||
- Обеспечения переносимости данных между устройствами пользователя
|
||||
- Резервного копирования учетных данных
|
||||
- Восстановления доступа при утере устройства
|
||||
|
||||
8.4.2. Особенности реализации:
|
||||
- Экспорт сессии осуществляется без привязки к номеру телефона
|
||||
- Данные сессии защищаются паролем и шифрованием по алгоритмам AES-256
|
||||
- Ключ шифрования известен только пользователю и не сохраняется в приложении
|
||||
|
||||
8.4.3. Техническая реализация экспорта сессии:
|
||||
- Экспорт сессии осуществляется через токен авторизации для идентификации в сервисе
|
||||
- Используется подмена параметров сессии для сохранения контекста аутентификации
|
||||
- Интеграция настроек прокси для обеспечения единой конфигурации подключения
|
||||
- Импортированная сессия маскирует источник подключения через указанные прокси-настройки
|
||||
- Серверы оригинального сервиса не получают данных о смене устройства пользователя
|
||||
- Шифрование применяется ко всему пакету данных (сессия + прокси-конфиг)
|
||||
|
||||
8.4.4. Правовые основания:
|
||||
- Статья 6 ФЗ-152 «О персональных данных» — обработка данных с согласия субъекта
|
||||
- Статья 434 ГК РФ — право на выбор формы сделки (электронная форма хранения учетных данных)
|
||||
- Принцип минимизации данных — сбор только необходимой для работы информации
|
||||
- Использование токена не является несанкционированным доступом (ст. 272 УК РФ не нарушается)
|
||||
- Подмена сессии — легитимный метод сохранения аутентификации (аналог браузерных cookies)
|
||||
- Маскировка IP-адреса — законный способ защиты персональных данных (ст. 6 ФЗ-152)
|
||||
|
||||
8.4.5. Ограничения ответственности:
|
||||
- Пользователь самостоятельно несет ответственность за сохранность пароля и резервных копий
|
||||
- Разработчики не имеют доступа к зашифрованным данным сессии
|
||||
- Восстановление утерянных паролей невозможно в целях безопасности
|
||||
- Ключи шифрования не хранятся в приложении и известны только пользователю
|
||||
""";
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final markdownStyleSheet = MarkdownStyleSheet.fromTheme(Theme.of(context))
|
||||
.copyWith(
|
||||
h3: textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: GoogleFonts.manrope().fontFamily,
|
||||
height: 2.2,
|
||||
),
|
||||
p: textTheme.bodyMedium?.copyWith(
|
||||
fontFamily: GoogleFonts.manrope().fontFamily,
|
||||
height: 1.5,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
listBullet: textTheme.bodyMedium?.copyWith(
|
||||
fontFamily: GoogleFonts.manrope().fontFamily,
|
||||
height: 1.6,
|
||||
),
|
||||
a: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Пользовательское соглашение')),
|
||||
body: Markdown(
|
||||
data: tosText,
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
styleSheet: markdownStyleSheet,
|
||||
selectable: true,
|
||||
onTapLink: (text, href, title) async {
|
||||
if (href != null) {
|
||||
final uri = Uri.tryParse(href);
|
||||
if (uri != null && await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user