From 2d11f1cba2fc17e62d739bcab8009bb3920ab518 Mon Sep 17 00:00:00 2001 From: needle10 Date: Tue, 18 Nov 2025 23:13:55 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BC=D1=83=D0=BB=D1=8C=D1=82=D0=B8=D0=B0?= =?UTF-8?q?=D0=BA=D0=BA=D0=B0=D1=83=D0=BD=D0=B3=D1=82=D0=B8=D0=BD=D0=B3(?= =?UTF-8?q?=D0=B1=D0=B0=D0=B3=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D0=B9,?= =?UTF-8?q?=20=D0=BD=D0=BE=20=D0=BE=D0=BD=20=D0=B5=D1=81=D1=82=D1=8C),=20?= =?UTF-8?q?=D0=B8=D0=B7=D0=B1=D1=80=D0=B0=D0=BD=D0=BD=D0=BE=D0=B5=20=D0=BA?= =?UTF-8?q?=D0=BE=D1=80=D0=B5=D0=BA=D1=82=D0=BD=D0=BE=20=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D0=B6=D0=B0=D0=B5=D1=82=D1=81=D1=8F,=20?= =?UTF-8?q?=D1=83=D0=B1=D1=80=D0=B0=D0=BD=D0=B0=20=D0=BA=D0=BD=D0=BE=D0=BF?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=B8=D1=82=D1=8C?= =?UTF-8?q?=20=D0=B2=20=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=D0=B5,=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=B4=D0=B5=D1=80=D0=B6=D0=BA=D0=B0=20=D0=B2=D0=B8=D0=B4?= =?UTF-8?q?=D0=B5=D0=BE=D0=BA=D1=80=D1=83=D0=B6=D0=BA=D0=BE=D0=B2=20=D0=B8?= =?UTF-8?q?=20=D0=B3=D0=BE=D0=BB=D0=BE=D1=81=D0=BE=D0=B2=D1=8B=D1=85=20?= =?UTF-8?q?=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/api_service.dart | 133 ++- lib/chat_screen.dart | 50 +- lib/chats_screen.dart | 400 ++++++-- lib/main.dart | 11 +- lib/models/account.dart | 105 +++ .../settings/export_session_screen.dart | 59 +- lib/services/account_manager.dart | 173 ++++ lib/services/cache_service.dart | 177 +++- lib/widgets/chat_message_bubble.dart | 859 +++++++++++++++++- lib/widgets/group_header.dart | 6 +- lib/widgets/group_management_panel.dart | 10 +- macos/Flutter/GeneratedPluginRegistrant.swift | 4 + pubspec.lock | 32 + pubspec.yaml | 2 + 14 files changed, 1803 insertions(+), 218 deletions(-) create mode 100644 lib/models/account.dart create mode 100644 lib/services/account_manager.dart diff --git a/lib/api_service.dart b/lib/api_service.dart index 26aacb9..9e704da 100644 --- a/lib/api_service.dart +++ b/lib/api_service.dart @@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:web_socket_channel/io.dart'; import 'package:gwid/models/message.dart'; import 'package:gwid/models/contact.dart'; +import 'package:gwid/models/profile.dart'; import 'package:web_socket_channel/status.dart' as status; import 'package:http/http.dart' as http; import 'package:image_picker/image_picker.dart'; @@ -12,6 +13,7 @@ import 'package:uuid/uuid.dart'; import 'package:flutter/services.dart'; import 'package:gwid/proxy_service.dart'; import 'package:file_picker/file_picker.dart'; +import 'package:gwid/services/account_manager.dart'; class ApiService { ApiService._privateConstructor(); @@ -677,8 +679,7 @@ class ApiService { Future> getChatsOnly({bool force = false}) async { if (authToken == null) { - final prefs = await SharedPreferences.getInstance(); - authToken = prefs.getString('authToken'); + await _loadTokenFromAccountManager(); } if (authToken == null) throw Exception("Auth token not found"); @@ -985,6 +986,23 @@ class ApiService { final List chatListJson = chatResponse['payload']?['chats'] ?? []; + if (profile != null && authToken != null) { + try { + final accountManager = AccountManager(); + await accountManager.initialize(); + final currentAccount = accountManager.currentAccount; + if (currentAccount != null && currentAccount.token == authToken) { + final profileObj = Profile.fromJson(profile); + await accountManager.updateAccountProfile( + currentAccount.id, + profileObj, + ); + } + } catch (e) { + print('Ошибка сохранения профиля в AccountManager: $e'); + } + } + if (chatListJson.isEmpty) { if (config != null) { _processServerPrivacyConfig(config); @@ -1243,7 +1261,18 @@ class ApiService { Future _sendInitialSetupRequests() async { print("Запускаем отправку единичных запросов при старте..."); + if (!_isSessionOnline || !_isSessionReady) { + print("Сессия еще не готова, ждем..."); + await waitUntilOnline(); + } + await Future.delayed(const Duration(seconds: 2)); + + if (!_isSessionOnline || !_isSessionReady) { + print("Сессия не готова для отправки запросов, пропускаем"); + return; + } + _sendMessage(272, {"folderSync": 0}); await Future.delayed(const Duration(milliseconds: 500)); _sendMessage(27, {"sync": 0, "type": "STICKER"}); @@ -1914,42 +1943,118 @@ class ApiService { }); } - Future saveToken(String token, {String? userId}) async { + Future saveToken( + String token, { + String? userId, + Profile? profile, + }) async { print("Сохраняем новый токен: ${token.substring(0, 20)}..."); if (userId != null) { print("Сохраняем UserID: $userId"); } + + final accountManager = AccountManager(); + await accountManager.initialize(); + final account = await accountManager.addAccount( + token: token, + userId: userId, + profile: profile, + ); + await accountManager.switchAccount(account.id); + authToken = token; + this.userId = userId; + final prefs = await SharedPreferences.getInstance(); await prefs.setString('authToken', token); + if (userId != null) { + await prefs.setString('userId', userId); + } disconnect(); await connect(); await getChatsAndContacts(force: true); - if (userId != null) { - await prefs.setString('userId', userId); - } - print("Токен и UserID успешно сохранены в SharedPreferences"); + print("Токен и UserID успешно сохранены"); } Future hasToken() async { if (authToken == null) { - final prefs = await SharedPreferences.getInstance(); - authToken = prefs.getString('authToken'); - userId = prefs.getString('userId'); - if (authToken != null) { + final accountManager = AccountManager(); + await accountManager.initialize(); + await accountManager.migrateOldAccount(); + + final currentAccount = accountManager.currentAccount; + if (currentAccount != null) { + authToken = currentAccount.token; + userId = currentAccount.userId; print( - "Токен загружен из SharedPreferences: ${authToken!.substring(0, 20)}...", + "Токен загружен из AccountManager: ${authToken!.substring(0, 20)}...", ); - if (userId != null) { - print("UserID загружен из SharedPreferences: $userId"); + } else { + // Fallback на старый способ для обратной совместимости + final prefs = await SharedPreferences.getInstance(); + authToken = prefs.getString('authToken'); + userId = prefs.getString('userId'); + if (authToken != null) { + print( + "Токен загружен из SharedPreferences: ${authToken!.substring(0, 20)}...", + ); + if (userId != null) { + print("UserID загружен из SharedPreferences: $userId"); + } } } } return authToken != null; } + Future _loadTokenFromAccountManager() async { + final accountManager = AccountManager(); + await accountManager.initialize(); + final currentAccount = accountManager.currentAccount; + if (currentAccount != null) { + authToken = currentAccount.token; + userId = currentAccount.userId; + } + } + + Future switchAccount(String accountId) async { + print("Переключение на аккаунт: $accountId"); + + disconnect(); + + final accountManager = AccountManager(); + await accountManager.initialize(); + await accountManager.switchAccount(accountId); + + final currentAccount = accountManager.currentAccount; + if (currentAccount != null) { + authToken = currentAccount.token; + userId = currentAccount.userId; + + _messageCache.clear(); + _messageQueue.clear(); + _lastChatsPayload = null; + _chatsFetchedInThisSession = false; + _isSessionOnline = false; + _isSessionReady = false; + _handshakeSent = false; + + await connect(); + + await waitUntilOnline(); + + await getChatsAndContacts(force: true); + + final profile = _lastChatsPayload?['profile']; + if (profile != null) { + final profileObj = Profile.fromJson(profile); + await accountManager.updateAccountProfile(accountId, profileObj); + } + } + } + Future> fetchContactsByIds(List contactIds) async { if (contactIds.isEmpty) { return []; diff --git a/lib/chat_screen.dart b/lib/chat_screen.dart index 04380cb..6ab3be4 100644 --- a/lib/chat_screen.dart +++ b/lib/chat_screen.dart @@ -110,6 +110,9 @@ class _ChatScreenState extends State { final Map _messageKeys = {}; void _checkContactCache() { + if (widget.chatId == 0) { + return; + } final cachedContact = ApiService.instance.getCachedContact( widget.contact.id, ); @@ -208,6 +211,9 @@ class _ChatScreenState extends State { } ApiService.instance.contactUpdates.listen((contact) { + if (widget.chatId == 0) { + return; + } if (contact.id == _currentContact.id && mounted) { ApiService.instance.updateCachedContact(contact); setState(() { @@ -1681,7 +1687,9 @@ class _ChatScreenState extends State { deferImageLoading: deferImageLoading, myUserId: _actualMyId, chatId: widget.chatId, - onReply: () => _replyToMessage(item.message), + onReply: widget.isChannel + ? null + : () => _replyToMessage(item.message), onForward: () => _forwardMessage(item.message), onEdit: isMe ? () => _editMessage(item.message) : null, canEditMessage: isMe @@ -2059,19 +2067,31 @@ class _ChatScreenState extends State { onTap: _showContactProfile, child: Hero( tag: 'contact_avatar_${widget.contact.id}', - child: CircleAvatar( - radius: 18, - backgroundImage: widget.contact.photoBaseUrl != null - ? NetworkImage(widget.contact.photoBaseUrl!) - : null, - child: widget.contact.photoBaseUrl == null - ? Text( - widget.contact.name.isNotEmpty - ? widget.contact.name[0].toUpperCase() - : '?', - ) - : null, - ), + child: widget.chatId == 0 + ? CircleAvatar( + radius: 18, + backgroundColor: Theme.of( + context, + ).colorScheme.primaryContainer, + child: Icon( + Icons.bookmark, + size: 20, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ) + : CircleAvatar( + radius: 18, + backgroundImage: widget.contact.photoBaseUrl != null + ? NetworkImage(widget.contact.photoBaseUrl!) + : null, + child: widget.contact.photoBaseUrl == null + ? Text( + widget.contact.name.isNotEmpty + ? widget.contact.name[0].toUpperCase() + : '?', + ) + : null, + ), ), ), const SizedBox(width: 8), @@ -2135,7 +2155,7 @@ class _ChatScreenState extends State { color: Theme.of(context).colorScheme.onSurfaceVariant, ), ) - else + else if (widget.chatId != 0) _ContactPresenceSubtitle( chatId: widget.chatId, userId: widget.contact.id, diff --git a/lib/chats_screen.dart b/lib/chats_screen.dart index 1511f6c..ede4f82 100644 --- a/lib/chats_screen.dart +++ b/lib/chats_screen.dart @@ -27,6 +27,8 @@ import 'package:gwid/downloads_screen.dart'; import 'package:gwid/user_id_lookup_screen.dart'; import 'package:gwid/widgets/message_preview_dialog.dart'; import 'package:gwid/services/chat_read_settings_service.dart'; +import 'package:gwid/services/account_manager.dart'; +import 'package:gwid/models/account.dart'; class SearchResult { final Chat chat; @@ -90,6 +92,7 @@ class _ChatsScreenState extends State String _connectionStatus = 'connecting'; StreamSubscription? _connectionStatusSubscription; StreamSubscription? _connectionStateSubscription; + bool _isAccountsExpanded = false; @override void initState() { @@ -158,6 +161,21 @@ class _ChatsScreenState extends State _isProfileLoading = true; }); + try { + final accountManager = AccountManager(); + await accountManager.initialize(); + final currentAccount = accountManager.currentAccount; + if (currentAccount?.profile != null && mounted) { + setState(() { + _myProfile = currentAccount!.profile; + _isProfileLoading = false; + }); + return; + } + } catch (e) { + print('Ошибка загрузки профиля из AccountManager: $e'); + } + final cachedProfileData = ApiService.instance.lastChatsPayload?['profile']; if (cachedProfileData != null && mounted) { setState(() { @@ -1735,93 +1753,258 @@ class _ChatsScreenState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Container( - width: double.infinity, + FutureBuilder>( + future: _loadAccounts(), + builder: (context, accountsSnapshot) { + final accounts = accountsSnapshot.data ?? []; + final accountManager = AccountManager(); + final currentAccount = accountManager.currentAccount; + final hasMultipleAccounts = accounts.length > 1; - padding: EdgeInsets.only( - top: MediaQuery.of(context).padding.top + 16.0, - left: 16.0, - right: 16.0, - bottom: 16.0, - ), - decoration: BoxDecoration(color: colors.primaryContainer), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CircleAvatar( - radius: 30, // Чуть крупнее - backgroundColor: colors.primary, - backgroundImage: - _isProfileLoading || _myProfile?.photoBaseUrl == null - ? null - : NetworkImage(_myProfile!.photoBaseUrl!), - child: _isProfileLoading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, + return Column( + children: [ + Container( + width: double.infinity, + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top + 16.0, + left: 16.0, + right: 16.0, + bottom: 16.0, + ), + decoration: BoxDecoration(color: colors.primaryContainer), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 30, // Чуть крупнее + backgroundColor: colors.primary, + backgroundImage: + _isProfileLoading || + _myProfile?.photoBaseUrl == null + ? null + : NetworkImage(_myProfile!.photoBaseUrl!), + child: _isProfileLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : (_myProfile?.photoBaseUrl == null + ? Text( + _myProfile + ?.displayName + .isNotEmpty == + true + ? _myProfile!.displayName[0] + .toUpperCase() + : '?', + style: TextStyle( + color: colors.onPrimary, + fontSize: 28, // Крупнее + ), + ) + : null), + ), + IconButton( + icon: Icon( + isDarkMode + ? Icons.brightness_7 + : Icons.brightness_4, // Солнце / Луна + color: colors.onPrimaryContainer, + size: 26, ), + onPressed: () { + themeProvider.toggleTheme(); + }, + tooltip: isDarkMode + ? 'Светлая тема' + : 'Темная тема', + ), + ], + ), + const SizedBox(height: 12), + + Text( + _myProfile?.displayName ?? 'Загрузка...', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: colors.onPrimaryContainer, + ), + ), + const SizedBox(height: 4), + + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + _myProfile?.formattedPhone ?? '', + style: TextStyle( + color: colors.onPrimaryContainer.withOpacity( + 0.8, + ), + fontSize: 14, + ), + ), + ), + InkWell( + onTap: () { + setState(() { + _isAccountsExpanded = !_isAccountsExpanded; + }); + }, + child: Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Icon( + _isAccountsExpanded + ? Icons.expand_less + : Icons.expand_more, + color: colors.onPrimaryContainer, + size: 24, + ), + ), + ), + ], + ), + ], + ), + ), + + ClipRect( + child: AnimatedSize( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOutCubic, + child: _isAccountsExpanded + ? Column( + children: [ + if (hasMultipleAccounts) + ...accounts.map((account) { + final isCurrent = + account.id == currentAccount?.id; + return ListTile( + leading: CircleAvatar( + radius: 20, + backgroundColor: isCurrent + ? colors.primary + : colors.surfaceVariant, + backgroundImage: + account.avatarUrl != null + ? NetworkImage(account.avatarUrl!) + : null, + child: account.avatarUrl == null + ? Text( + account.displayName.isNotEmpty + ? account.displayName[0] + .toUpperCase() + : '?', + style: TextStyle( + color: isCurrent + ? colors.onPrimary + : colors.onSurfaceVariant, + fontSize: 16, + ), + ) + : null, + ), + title: Text( + account.displayName, + style: TextStyle( + fontWeight: isCurrent + ? FontWeight.bold + : FontWeight.normal, + ), + ), + subtitle: account.displayPhone.isNotEmpty + ? Text(account.displayPhone) + : null, + trailing: isCurrent + ? Icon( + Icons.check_circle, + color: colors.primary, + size: 20, + ) + : null, + onTap: isCurrent + ? null + : () async { + Navigator.pop(context); + try { + await ApiService.instance + .switchAccount(account.id); + if (mounted) { + setState(() { + _isAccountsExpanded = false; + _loadMyProfile(); + _chatsFuture = (() async { + try { + await ApiService + .instance + .waitUntilOnline(); + return ApiService + .instance + .getChatsAndContacts(); + } catch (e) { + print( + 'Ошибка получения чатов: $e', + ); + rethrow; + } + })(); + }); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar( + SnackBar( + content: Text( + 'Ошибка переключения аккаунта: $e', + ), + backgroundColor: + colors.error, + ), + ); + } + } + }, + ); + }).toList(), + + ListTile( + leading: const Icon(Icons.add_circle_outline), + title: const Text('Добавить аккаунт'), + onTap: () { + Navigator.pop(context); + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => + const PhoneEntryScreen(), + ), + ); + }, + ), + ], ) - : (_myProfile?.photoBaseUrl == null - ? Text( - _myProfile?.displayName.isNotEmpty == true - ? _myProfile!.displayName[0] - .toUpperCase() - : '?', - style: TextStyle( - color: colors.onPrimary, - fontSize: 28, // Крупнее - ), - ) - : null), + : const SizedBox.shrink(), ), - IconButton( - icon: Icon( - isDarkMode - ? Icons.brightness_7 - : Icons.brightness_4, // Солнце / Луна - color: colors.onPrimaryContainer, - size: 26, - ), - onPressed: () { - themeProvider.toggleTheme(); - }, - tooltip: isDarkMode ? 'Светлая тема' : 'Темная тема', - ), - ], - ), - const SizedBox(height: 12), - - Text( - _myProfile?.displayName ?? 'Загрузка...', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: colors.onPrimaryContainer, ), - ), - const SizedBox(height: 4), - - Text( - _myProfile?.formattedPhone ?? '', - style: TextStyle( - color: colors.onPrimaryContainer.withOpacity(0.8), - fontSize: 14, - ), - ), - ], - ), + ], + ); + }, ), - Expanded( child: Column( children: [ + _buildAccountsSection(context, colors), ListTile( leading: const Icon(Icons.person_outline), title: const Text('Мой профиль'), @@ -1905,6 +2088,16 @@ class _ChatsScreenState extends State ); } + Widget _buildAccountsSection(BuildContext context, ColorScheme colors) { + return const SizedBox.shrink(); + } + + Future> _loadAccounts() async { + final accountManager = AccountManager(); + await accountManager.initialize(); + return accountManager.accounts; + } + Widget _buildSearchResults() { final colors = Theme.of(context).colorScheme; @@ -2002,10 +2195,23 @@ class _ChatsScreenState extends State final participantCount = chat.participantsCount ?? chat.participantIds.length; + final Contact contactToUse = isSavedMessages + ? Contact( + id: chat.id, + name: "Избранное", + firstName: "", + lastName: "", + photoBaseUrl: null, + description: null, + isBlocked: false, + isBlockedByMe: false, + ) + : contact; + if (widget.onChatSelected != null) { widget.onChatSelected!( chat, - contact, + contactToUse, isGroupChat, isChannel, participantCount, @@ -2015,7 +2221,7 @@ class _ChatsScreenState extends State MaterialPageRoute( builder: (context) => ChatScreen( chatId: chat.id, - contact: contact, + contact: contactToUse, myId: chat.ownerId, isGroupChat: isGroupChat, isChannel: isChannel, @@ -3587,18 +3793,28 @@ class _ChatsScreenState extends State } } - final Contact contactFallback = - contact ?? - Contact( - id: chat.id, - name: title, - firstName: "", - lastName: "", - photoBaseUrl: avatarUrl, - description: isChannel ? chat.description : null, - isBlocked: false, - isBlockedByMe: false, - ); + final Contact contactFallback = isSavedMessages + ? Contact( + id: chat.id, + name: "Избранное", + firstName: "", + lastName: "", + photoBaseUrl: null, + description: null, + isBlocked: false, + isBlockedByMe: false, + ) + : contact ?? + Contact( + id: chat.id, + name: title, + firstName: "", + lastName: "", + photoBaseUrl: avatarUrl, + description: isChannel ? chat.description : null, + isBlocked: false, + isBlockedByMe: false, + ); final participantCount = chat.participantsCount ?? chat.participantIds.length; diff --git a/lib/main.dart b/lib/main.dart index 3b17d98..9aae874 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,3 @@ - - import 'package:flutter/material.dart'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; @@ -17,6 +15,7 @@ import 'services/cache_service.dart'; import 'services/avatar_cache_service.dart'; import 'services/chat_cache_service.dart'; import 'services/version_checker.dart'; +import 'services/account_manager.dart'; final GlobalKey navigatorKey = GlobalKey(); @@ -24,18 +23,20 @@ Future main() async { WidgetsFlutterBinding.ensureInitialized(); await initializeDateFormatting(); - print("Инициализируем сервисы кеширования..."); await CacheService().initialize(); await AvatarCacheService().initialize(); await ChatCacheService().initialize(); print("Сервисы кеширования инициализированы"); + print("Инициализируем AccountManager..."); + await AccountManager().initialize(); + await AccountManager().migrateOldAccount(); + print("AccountManager инициализирован"); final hasToken = await ApiService.instance.hasToken(); print("При запуске приложения токен ${hasToken ? 'найден' : 'не найден'}"); - if (hasToken) { print("Инициируем подключение к WebSocket при запуске..."); ApiService.instance.connect(); @@ -60,7 +61,6 @@ class MyApp extends StatelessWidget { return DynamicColorBuilder( builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { - final Color accentColor = (themeProvider.appTheme == AppTheme.system && lightDynamic != null) ? lightDynamic.primary @@ -167,7 +167,6 @@ class MyApp extends StatelessWidget { } } - class _MiniFpsHud extends StatefulWidget { const _MiniFpsHud(); diff --git a/lib/models/account.dart b/lib/models/account.dart new file mode 100644 index 0000000..1144cc2 --- /dev/null +++ b/lib/models/account.dart @@ -0,0 +1,105 @@ +import 'package:gwid/models/profile.dart'; + +class Account { + final String id; + final String token; + final String? userId; + final Profile? profile; + final DateTime createdAt; + final DateTime? lastUsedAt; + + Account({ + required this.id, + required this.token, + this.userId, + this.profile, + required this.createdAt, + this.lastUsedAt, + }); + + String get displayName { + if (profile != null) { + return profile!.displayName; + } + if (userId != null) { + return 'Аккаунт $userId'; + } + return 'Аккаунт ${id.substring(0, 8)}'; + } + + String get displayPhone { + if (profile != null) { + return profile!.formattedPhone; + } + return ''; + } + + String? get avatarUrl => profile?.photoBaseUrl; + + Map toJson() { + return { + 'id': id, + 'token': token, + 'userId': userId, + 'profile': profile != null + ? { + 'id': profile!.id, + 'phone': profile!.phone, + 'firstName': profile!.firstName, + 'lastName': profile!.lastName, + 'photoBaseUrl': profile!.photoBaseUrl, + } + : null, + 'createdAt': createdAt.toIso8601String(), + 'lastUsedAt': lastUsedAt?.toIso8601String(), + }; + } + + factory Account.fromJson(Map json) { + Profile? profile; + if (json['profile'] != null) { + final profileData = json['profile'] as Map; + profile = Profile( + id: profileData['id'] as int, + phone: profileData['phone'] as String, + firstName: profileData['firstName'] as String? ?? '', + lastName: profileData['lastName'] as String? ?? '', + photoBaseUrl: profileData['photoBaseUrl'] as String?, + photoId: 0, + updateTime: 0, + options: [], + accountStatus: 0, + profileOptions: [], + ); + } + + return Account( + id: json['id'] as String, + token: json['token'] as String, + userId: json['userId'] as String?, + profile: profile, + createdAt: DateTime.parse(json['createdAt'] as String), + lastUsedAt: json['lastUsedAt'] != null + ? DateTime.parse(json['lastUsedAt'] as String) + : null, + ); + } + + Account copyWith({ + String? id, + String? token, + String? userId, + Profile? profile, + DateTime? createdAt, + DateTime? lastUsedAt, + }) { + return Account( + id: id ?? this.id, + token: token ?? this.token, + userId: userId ?? this.userId, + profile: profile ?? this.profile, + createdAt: createdAt ?? this.createdAt, + lastUsedAt: lastUsedAt ?? this.lastUsedAt, + ); + } +} diff --git a/lib/screens/settings/export_session_screen.dart b/lib/screens/settings/export_session_screen.dart index f31afc9..f32d4f2 100644 --- a/lib/screens/settings/export_session_screen.dart +++ b/lib/screens/settings/export_session_screen.dart @@ -1,10 +1,8 @@ - - import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; -import 'package:path_provider/path_provider.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:gwid/api_service.dart'; import 'package:gwid/proxy_service.dart'; import 'package:gwid/spoofing_service.dart'; @@ -73,16 +71,55 @@ class _ExportSessionScreenState extends State { Uint8List bytes = Uint8List.fromList(utf8.encode(finalFileContent)); - final Directory directory = await getDownloadsDirectory() ?? await getApplicationDocumentsDirectory(); - final String filePath = '${directory.path}/komet_session_${DateTime.now().millisecondsSinceEpoch}.ksession'; - - final File file = File(filePath); - await file.writeAsBytes(bytes); + final String fileName = + 'komet_session_${DateTime.now().millisecondsSinceEpoch}.ksession'; + + String? outputFile; + + if (Platform.isAndroid || Platform.isIOS) { + outputFile = await FilePicker.platform.saveFile( + dialogTitle: 'Сохранить файл сессии...', + fileName: fileName, + allowedExtensions: ['ksession'], + type: FileType.custom, + bytes: bytes, + ); + } else { + outputFile = await FilePicker.platform.saveFile( + dialogTitle: 'Сохранить файл сессии...', + fileName: fileName, + allowedExtensions: ['ksession'], + type: FileType.custom, + ); + + if (outputFile != null) { + if (!outputFile.endsWith('.ksession')) { + outputFile += '.ksession'; + } + + final File file = File(outputFile); + await file.writeAsBytes(bytes); + } + } + + if (outputFile == null) { + if (mounted) { + messenger.showSnackBar( + const SnackBar(content: Text('Сохранение отменено')), + ); + } + return; + } if (mounted) { + String displayPath = outputFile; + if (Platform.isAndroid || Platform.isIOS) { + displayPath = fileName; + } + messenger.showSnackBar( SnackBar( - content: Text('Файл сессии успешно сохранен: $filePath'), + content: Text('Файл сессии успешно сохранен: $displayPath'), backgroundColor: Colors.green, ), ); @@ -122,7 +159,6 @@ class _ExportSessionScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Center( child: CircleAvatar( radius: 40, @@ -154,7 +190,6 @@ class _ExportSessionScreenState extends State { const Divider(), const SizedBox(height: 24), - Text( '1. Защитите файл паролем', style: textTheme.titleLarge?.copyWith( @@ -214,7 +249,6 @@ class _ExportSessionScreenState extends State { ), const SizedBox(height: 32), - Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -243,7 +277,6 @@ class _ExportSessionScreenState extends State { ), const SizedBox(height: 32), - FilledButton.icon( onPressed: _isExporting ? null : _exportAndSaveSession, icon: _isExporting diff --git a/lib/services/account_manager.dart b/lib/services/account_manager.dart new file mode 100644 index 0000000..6e6d107 --- /dev/null +++ b/lib/services/account_manager.dart @@ -0,0 +1,173 @@ +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:gwid/models/account.dart'; +import 'package:gwid/models/profile.dart'; +import 'package:uuid/uuid.dart'; + +class AccountManager { + static final AccountManager _instance = AccountManager._internal(); + factory AccountManager() => _instance; + AccountManager._internal(); + + static const String _accountsKey = 'multi_accounts'; + static const String _currentAccountIdKey = 'current_account_id'; + + Account? _currentAccount; + List _accounts = []; + + Account? get currentAccount => _currentAccount; + List get accounts => List.unmodifiable(_accounts); + + Future initialize() async { + await _loadAccounts(); + await _loadCurrentAccount(); + } + + Future _loadAccounts() async { + try { + final prefs = await SharedPreferences.getInstance(); + final accountsJson = prefs.getString(_accountsKey); + if (accountsJson != null) { + final List accountsList = jsonDecode(accountsJson); + _accounts = accountsList + .map((json) => Account.fromJson(json as Map)) + .toList(); + } + } catch (e) { + print('Ошибка загрузки аккаунтов: $e'); + _accounts = []; + } + } + + Future _loadCurrentAccount() async { + try { + final prefs = await SharedPreferences.getInstance(); + final currentAccountId = prefs.getString(_currentAccountIdKey); + + if (currentAccountId != null) { + _currentAccount = _accounts.firstWhere( + (account) => account.id == currentAccountId, + orElse: () => _accounts.isNotEmpty + ? _accounts.first + : Account(id: '', token: '', createdAt: DateTime.now()), + ); + } else if (_accounts.isNotEmpty) { + _currentAccount = _accounts.first; + await _saveCurrentAccountId(_currentAccount!.id); + } + } catch (e) { + print('Ошибка загрузки текущего аккаунта: $e'); + if (_accounts.isNotEmpty) { + _currentAccount = _accounts.first; + } + } + } + + Future _saveAccounts() async { + try { + final prefs = await SharedPreferences.getInstance(); + final accountsJson = jsonEncode( + _accounts.map((account) => account.toJson()).toList(), + ); + await prefs.setString(_accountsKey, accountsJson); + } catch (e) { + print('Ошибка сохранения аккаунтов: $e'); + } + } + + Future _saveCurrentAccountId(String accountId) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_currentAccountIdKey, accountId); + } catch (e) { + print('Ошибка сохранения текущего аккаунта: $e'); + } + } + + Future addAccount({ + required String token, + String? userId, + Profile? profile, + }) async { + final account = Account( + id: const Uuid().v4(), + token: token, + userId: userId, + profile: profile, + createdAt: DateTime.now(), + lastUsedAt: DateTime.now(), + ); + + final existingIndex = _accounts.indexWhere((acc) => acc.token == token); + if (existingIndex != -1) { + _accounts[existingIndex] = account.copyWith( + id: _accounts[existingIndex].id, + ); + } else { + _accounts.add(account); + } + + await _saveAccounts(); + return account; + } + + Future switchAccount(String accountId) async { + final account = _accounts.firstWhere( + (acc) => acc.id == accountId, + orElse: () => throw Exception('Аккаунт не найден'), + ); + + _currentAccount = account; + await _saveCurrentAccountId(accountId); + + final index = _accounts.indexWhere((acc) => acc.id == accountId); + if (index != -1) { + _accounts[index] = _accounts[index].copyWith(lastUsedAt: DateTime.now()); + await _saveAccounts(); + } + } + + Future updateAccountProfile(String accountId, Profile profile) async { + final index = _accounts.indexWhere((acc) => acc.id == accountId); + if (index != -1) { + _accounts[index] = _accounts[index].copyWith(profile: profile); + await _saveAccounts(); + + if (_currentAccount?.id == accountId) { + _currentAccount = _accounts[index]; + } + } + } + + Future removeAccount(String accountId) async { + if (_accounts.length <= 1) { + throw Exception('Нельзя удалить последний аккаунт'); + } + + _accounts.removeWhere((acc) => acc.id == accountId); + + if (_currentAccount?.id == accountId) { + _currentAccount = _accounts.isNotEmpty ? _accounts.first : null; + if (_currentAccount != null) { + await _saveCurrentAccountId(_currentAccount!.id); + } + } + + await _saveAccounts(); + } + + Future migrateOldAccount() async { + try { + final prefs = await SharedPreferences.getInstance(); + final oldToken = prefs.getString('authToken'); + final oldUserId = prefs.getString('userId'); + + if (oldToken != null && _accounts.isEmpty) { + await addAccount(token: oldToken, userId: oldUserId); + print('Старый аккаунт мигрирован в мультиаккаунтинг'); + } + } catch (e) { + print('Ошибка миграции старого аккаунта: $e'); + } + } +} diff --git a/lib/services/cache_service.dart b/lib/services/cache_service.dart index f5a861e..41f365d 100644 --- a/lib/services/cache_service.dart +++ b/lib/services/cache_service.dart @@ -1,5 +1,3 @@ - - import 'dart:async'; import 'dart:io'; import 'dart:convert'; @@ -7,42 +5,41 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:path_provider/path_provider.dart'; import 'package:http/http.dart' as http; - class CacheService { static final CacheService _instance = CacheService._internal(); factory CacheService() => _instance; CacheService._internal(); - final Map _memoryCache = {}; final Map _cacheTimestamps = {}; - static const Duration _defaultTTL = Duration(hours: 24); static const int _maxMemoryCacheSize = 1000; - SharedPreferences? _prefs; - Directory? _cacheDirectory; - Future initialize() async { _prefs = await SharedPreferences.getInstance(); _cacheDirectory = await getApplicationCacheDirectory(); - await _createCacheDirectories(); print('CacheService инициализирован'); } - Future _createCacheDirectories() async { if (_cacheDirectory == null) return; - final directories = ['avatars', 'images', 'files', 'chats', 'contacts']; + final directories = [ + 'avatars', + 'images', + 'files', + 'chats', + 'contacts', + 'audio', + ]; for (final dir in directories) { final directory = Directory('${_cacheDirectory!.path}/$dir'); @@ -52,21 +49,17 @@ class CacheService { } } - Future get(String key, {Duration? ttl}) async { - if (_memoryCache.containsKey(key)) { final timestamp = _cacheTimestamps[key]; if (timestamp != null && !_isExpired(timestamp, ttl ?? _defaultTTL)) { return _memoryCache[key] as T?; } else { - _memoryCache.remove(key); _cacheTimestamps.remove(key); } } - if (_prefs != null) { try { final cacheKey = 'cache_$key'; @@ -80,7 +73,6 @@ class CacheService { final value = data['value']; if (!_isExpired(timestamp, ttl ?? _defaultTTL)) { - _memoryCache[key] = value; _cacheTimestamps[key] = timestamp; return value as T?; @@ -94,20 +86,16 @@ class CacheService { return null; } - Future set(String key, T value, {Duration? ttl}) async { final timestamp = DateTime.now(); - _memoryCache[key] = value; _cacheTimestamps[key] = timestamp; - if (_memoryCache.length > _maxMemoryCacheSize) { await _evictOldestMemoryCache(); } - if (_prefs != null) { try { final cacheKey = 'cache_$key'; @@ -124,7 +112,6 @@ class CacheService { } } - Future remove(String key) async { _memoryCache.remove(key); _cacheTimestamps.remove(key); @@ -139,14 +126,12 @@ class CacheService { } } - Future clear() async { _memoryCache.clear(); _cacheTimestamps.clear(); if (_prefs != null) { try { - final keys = _prefs!.getKeys().where((key) => key.startsWith('cache_')); for (final key in keys) { await _prefs!.remove(key); @@ -156,7 +141,6 @@ class CacheService { } } - if (_cacheDirectory != null) { try { for (final dir in ['avatars', 'images', 'files', 'chats', 'contacts']) { @@ -172,16 +156,13 @@ class CacheService { } } - bool _isExpired(DateTime timestamp, Duration ttl) { return DateTime.now().difference(timestamp) > ttl; } - Future _evictOldestMemoryCache() async { if (_memoryCache.isEmpty) return; - final sortedEntries = _cacheTimestamps.entries.toList() ..sort((a, b) => a.value.compareTo(b.value)); @@ -193,11 +174,9 @@ class CacheService { } } - Future> getCacheSize() async { final memorySize = _memoryCache.length; - int filesSize = 0; if (_cacheDirectory != null) { try { @@ -219,25 +198,20 @@ class CacheService { }; } - Future cacheFile(String url, {String? customKey}) async { if (_cacheDirectory == null) return null; try { - final fileName = _generateFileName(url, customKey); final filePath = '${_cacheDirectory!.path}/images/$fileName'; - final existingFile = File(filePath); if (await existingFile.exists()) { return filePath; } - final response = await http.get(Uri.parse(url)); if (response.statusCode == 200) { - await existingFile.writeAsBytes(response.bodyBytes); return filePath; } @@ -248,7 +222,6 @@ class CacheService { return null; } - Future getCachedFile(String url, {String? customKey}) async { if (_cacheDirectory == null) return null; @@ -267,33 +240,42 @@ class CacheService { return null; } - String _generateFileName(String url, String? customKey) { final key = customKey ?? url; - final hash = key.hashCode.abs().toString().substring(0, 16); + final hashString = key.hashCode.abs().toString(); + final hash = hashString.length >= 16 + ? hashString.substring(0, 16) + : hashString.padRight(16, '0'); final extension = _getFileExtension(url); return '$hash$extension'; } - String _getFileExtension(String url) { try { final uri = Uri.parse(url); final path = uri.path; final extension = path.substring(path.lastIndexOf('.')); - return extension.isNotEmpty && extension.length < 10 ? extension : '.jpg'; + if (extension.isNotEmpty && extension.length < 10) { + return extension; + } + if (url.contains('audio') || + url.contains('voice') || + url.contains('.mp3') || + url.contains('.ogg') || + url.contains('.m4a')) { + return '.mp3'; + } + return '.jpg'; } catch (e) { return '.jpg'; } } - Future hasCachedFile(String url, {String? customKey}) async { final file = await getCachedFile(url, customKey: customKey); return file != null; } - Future> getDetailedCacheStats() async { final memorySize = _memoryCache.length; final cacheSize = await getCacheSize(); @@ -308,12 +290,7 @@ class CacheService { }; } - - Future removeCachedFile(String url, {String? customKey}) async { - - - } - + Future removeCachedFile(String url, {String? customKey}) async {} Future> getCacheStats() async { final sizes = await getCacheSize(); @@ -329,4 +306,110 @@ class CacheService { 'maxMemorySize': _maxMemoryCacheSize, }; } + + Future cacheAudioFile(String url, {String? customKey}) async { + if (_cacheDirectory == null) { + print('CacheService: _cacheDirectory is null, initializing...'); + await initialize(); + if (_cacheDirectory == null) { + print('CacheService: Failed to initialize cache directory'); + return null; + } + } + + try { + final fileName = _generateFileName(url, customKey); + final filePath = '${_cacheDirectory!.path}/audio/$fileName'; + + final existingFile = File(filePath); + if (await existingFile.exists()) { + print('CacheService: Audio file already cached: $filePath'); + return filePath; + } + + print('CacheService: Downloading audio from: $url'); + print('CacheService: Target file path: $filePath'); + + final response = await http + .get( + Uri.parse(url), + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + }, + ) + .timeout( + const Duration(seconds: 30), + onTimeout: () { + print('CacheService: Request timeout'); + throw TimeoutException('Request timeout'); + }, + ); + + print( + 'CacheService: Response status: ${response.statusCode}, content-length: ${response.contentLength}', + ); + + if (response.statusCode == 200) { + if (response.bodyBytes.isEmpty) { + print('CacheService: Response body is empty'); + return null; + } + + final audioDir = Directory('${_cacheDirectory!.path}/audio'); + if (!await audioDir.exists()) { + await audioDir.create(recursive: true); + } + + await existingFile.writeAsBytes(response.bodyBytes); + final fileSize = await existingFile.length(); + print( + 'CacheService: Audio cached successfully: $filePath (size: $fileSize bytes)', + ); + return filePath; + } else { + print( + 'CacheService: Failed to download audio, status code: ${response.statusCode}', + ); + print( + 'CacheService: Response body: ${response.body.substring(0, response.body.length > 200 ? 200 : response.body.length)}', + ); + } + } catch (e, stackTrace) { + print('Ошибка кэширования аудио файла $url: $e'); + print('Stack trace: $stackTrace'); + if (e is TimeoutException) { + print('CacheService: Request timed out'); + } else if (e is SocketException) { + print('CacheService: Network error - ${e.message}'); + } else if (e is HttpException) { + print('CacheService: HTTP error - ${e.message}'); + } + } + + return null; + } + + Future getCachedAudioFile(String url, {String? customKey}) async { + if (_cacheDirectory == null) return null; + + try { + final fileName = _generateFileName(url, customKey); + final filePath = '${_cacheDirectory!.path}/audio/$fileName'; + + final file = File(filePath); + if (await file.exists()) { + return file; + } + } catch (e) { + print('Ошибка получения кэшированного аудио файла: $e'); + } + + return null; + } + + Future hasCachedAudioFile(String url, {String? customKey}) async { + final file = await getCachedAudioFile(url, customKey: customKey); + return file != null; + } } diff --git a/lib/widgets/chat_message_bubble.dart b/lib/widgets/chat_message_bubble.dart index 7265fc0..ef42ffd 100644 --- a/lib/widgets/chat_message_bubble.dart +++ b/lib/widgets/chat_message_bubble.dart @@ -22,6 +22,9 @@ import 'dart:async'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:open_file/open_file.dart'; import 'package:gwid/full_screen_video_player.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:gwid/services/cache_service.dart'; +import 'package:video_player/video_player.dart'; bool _currentIsDark = false; @@ -400,15 +403,30 @@ class ChatMessageBubble extends StatelessWidget { ); } - Widget _buildVideoPreview({ + Widget _buildVideoCirclePlayer({ required BuildContext context, required int videoId, required String messageId, String? highQualityUrl, Uint8List? lowQualityBytes, }) { - final borderRadius = BorderRadius.circular(12); + return _VideoCirclePlayer( + videoId: videoId, + messageId: messageId, + chatId: chatId!, + highQualityUrl: highQualityUrl, + lowQualityBytes: lowQualityBytes, + ); + } + Widget _buildVideoPreview({ + required BuildContext context, + required int videoId, + required String messageId, + String? highQualityUrl, + Uint8List? lowQualityBytes, + int? videoType, + }) { // Логика открытия плеера void openFullScreenVideo() async { // Показываем индикатор загрузки, пока получаем URL @@ -445,32 +463,39 @@ class ChatMessageBubble extends StatelessWidget { } } - // Виджет-контейнер (GestureDetector + Stack) + final isVideoCircle = videoType == 1; + + if (isVideoCircle) { + return _buildVideoCirclePlayer( + context: context, + videoId: videoId, + messageId: messageId, + highQualityUrl: highQualityUrl, + lowQualityBytes: lowQualityBytes, + ); + } + return GestureDetector( onTap: openFullScreenVideo, child: AspectRatio( aspectRatio: 16 / 9, child: ClipRRect( - borderRadius: borderRadius, + borderRadius: BorderRadius.circular(12), child: Stack( alignment: Alignment.center, fit: StackFit.expand, children: [ - // [!code ++] (НОВЫЙ БЛОК) // Если у нас есть ХОТЬ ЧТО-ТО (блюр или URL), показываем ProgressiveImage (highQualityUrl != null && highQualityUrl.isNotEmpty) || (lowQualityBytes != null) ? _ProgressiveNetworkImage( - url: - highQualityUrl ?? - '', // _ProgressiveNetworkImage теперь это выдержит + url: highQualityUrl ?? '', previewBytes: lowQualityBytes, width: 220, height: 160, fit: BoxFit.cover, keepAlive: false, ) - // ИНАЧЕ показываем нашу стандартную заглушку (а не пустоту) : Container( color: Colors.black26, child: const Center( @@ -481,12 +506,8 @@ class ChatMessageBubble extends StatelessWidget { ), ), ), - // [!code ++] (КОНЕЦ НОВОГО БЛОКА) - - // Иконка Play поверх (она будет поверх заглушки или картинки) Container( decoration: BoxDecoration( - // Небольшое затемнение, чтобы иконка была виднее color: Colors.black.withOpacity(0.15), ), child: Icon( @@ -765,7 +786,7 @@ class ChatMessageBubble extends StatelessWidget { const Divider(height: 1), ], // Действия с сообщением (остаются без изменений) - if (onReply != null) + if (onReply != null && !isChannel) ListTile( leading: const Icon(Icons.reply), title: const Text('Ответить'), @@ -871,6 +892,7 @@ class ChatMessageBubble extends StatelessWidget { onForward: onForward, canEditMessage: canEditMessage ?? false, hasUserReaction: hasUserReaction, + isChannel: isChannel, ); }, ); @@ -947,6 +969,16 @@ class ChatMessageBubble extends StatelessWidget { return _buildStickerOnlyMessage(context); } + final isVideoCircle = + message.attaches.length == 1 && + message.attaches.any( + (a) => a['_type'] == 'VIDEO' && (a['videoType'] as int?) == 1, + ) && + message.text.isEmpty; + if (isVideoCircle) { + return _buildVideoCircleOnlyMessage(context); + } + final hasUnsupportedContent = _hasUnsupportedMessageTypes(); final messageOpacity = themeProvider.messageBubbleOpacity; @@ -1176,8 +1208,7 @@ class ChatMessageBubble extends StatelessWidget { bool _hasUnsupportedMessageTypes() { final hasUnsupportedAttachments = message.attaches.any((attach) { final type = attach['_type']?.toString().toUpperCase(); - return type == 'AUDIO' || - type == 'VOICE' || + return type == 'VOICE' || type == 'GIF' || type == 'LOCATION' || type == 'CONTACT'; @@ -1334,7 +1365,7 @@ class ChatMessageBubble extends StatelessWidget { Widget _buildStickerOnlyMessage(BuildContext context) { final sticker = message.attaches.firstWhere((a) => a['_type'] == 'STICKER'); - final stickerSize = 250.0; + final stickerSize = 170.0; final timeColor = Theme.of(context).brightness == Brightness.dark ? const Color(0xFF9bb5c7) @@ -1397,6 +1428,97 @@ class ChatMessageBubble extends StatelessWidget { ); } + Widget _buildVideoCircleOnlyMessage(BuildContext context) { + final video = message.attaches.firstWhere((a) => a['_type'] == 'VIDEO'); + final videoId = video['videoId'] as int?; + final previewData = video['previewData'] as String?; + final thumbnailUrl = video['url'] ?? video['baseUrl'] as String?; + + Uint8List? previewBytes; + if (previewData != null && previewData.startsWith('data:')) { + final idx = previewData.indexOf('base64,'); + if (idx != -1) { + final b64 = previewData.substring(idx + 7); + try { + previewBytes = base64Decode(b64); + } catch (_) {} + } + } + + String? highQualityThumbnailUrl; + if (thumbnailUrl != null && thumbnailUrl.isNotEmpty) { + highQualityThumbnailUrl = thumbnailUrl; + if (!thumbnailUrl.contains('?')) { + highQualityThumbnailUrl = + '$thumbnailUrl?size=medium&quality=high&format=jpeg'; + } else { + highQualityThumbnailUrl = + '$thumbnailUrl&size=medium&quality=high&format=jpeg'; + } + } + + final timeColor = Theme.of(context).brightness == Brightness.dark + ? const Color(0xFF9bb5c7) + : const Color(0xFF6b7280); + + Widget videoContent = Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + child: Column( + crossAxisAlignment: isMe + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: isMe + ? MainAxisAlignment.end + : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Column( + crossAxisAlignment: isMe + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + if (videoId != null && chatId != null) + _buildVideoCirclePlayer( + context: context, + videoId: videoId, + messageId: message.id, + highQualityUrl: highQualityThumbnailUrl, + lowQualityBytes: previewBytes, + ), + Padding( + padding: const EdgeInsets.only(top: 4, right: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _formatMessageTime(context, message.time), + style: TextStyle(fontSize: 12, color: timeColor), + ), + ], + ), + ), + ], + ), + ], + ), + ], + ), + ); + + if (onReaction != null || (isMe && (onEdit != null || onDelete != null))) { + videoContent = GestureDetector( + onTapDown: (TapDownDetails details) { + _showMessageContextMenu(context, details.globalPosition); + }, + child: videoContent, + ); + } + + return videoContent; + } + Widget _buildStickerImage( BuildContext context, Map sticker, @@ -1479,6 +1601,7 @@ class ChatMessageBubble extends StatelessWidget { for (final video in videos) { // 1. Извлекаем все, что нам нужно final videoId = video['videoId'] as int?; + final videoType = video['videoType'] as int?; final previewData = video['previewData'] as String?; // Блюр-превью final thumbnailUrl = video['url'] ?? video['baseUrl'] as String?; // HQ-превью URL @@ -1519,6 +1642,7 @@ class ChatMessageBubble extends StatelessWidget { messageId: message.id, highQualityUrl: highQualityThumbnailUrl, lowQualityBytes: previewBytes, + videoType: videoType, ), ), ); @@ -1576,7 +1700,7 @@ class ChatMessageBubble extends StatelessWidget { bool isUltraOptimized, ) { // Стикеры обычно квадратные, около 200-250px - final stickerSize = 250.0; + final stickerSize = 170.0; return ConstrainedBox( constraints: BoxConstraints( @@ -1994,6 +2118,65 @@ class ChatMessageBubble extends StatelessWidget { } } + List _buildAudioWithCaption( + BuildContext context, + List> attaches, + Color textColor, + bool isUltraOptimized, + double messageTextOpacity, + ) { + final audioMessages = attaches.where((a) => a['_type'] == 'AUDIO').toList(); + final List widgets = []; + + if (audioMessages.isEmpty) return widgets; + + for (final audio in audioMessages) { + widgets.add( + _buildAudioWidget( + context, + audio, + textColor, + isUltraOptimized, + messageTextOpacity, + ), + ); + widgets.add(const SizedBox(height: 6)); + } + + return widgets; + } + + Widget _buildAudioWidget( + BuildContext context, + Map audioData, + Color textColor, + bool isUltraOptimized, + double messageTextOpacity, + ) { + final borderRadius = BorderRadius.circular(isUltraOptimized ? 8 : 12); + final url = audioData['url'] as String?; + final duration = audioData['duration'] as int? ?? 0; + final wave = audioData['wave'] as String?; + final audioId = audioData['audioId'] as int?; + + // Format duration + final durationSeconds = (duration / 1000).round(); + final minutes = durationSeconds ~/ 60; + final seconds = durationSeconds % 60; + final durationText = '$minutes:${seconds.toString().padLeft(2, '0')}'; + + return _AudioPlayerWidget( + url: url ?? '', + duration: duration, + durationText: durationText, + wave: wave, + audioId: audioId, + textColor: textColor, + borderRadius: borderRadius, + messageTextOpacity: messageTextOpacity, + ); + } + Future _handleFileDownload( BuildContext context, int? fileId, @@ -2830,6 +3013,13 @@ class ChatMessageBubble extends StatelessWidget { isUltraOptimized, messageTextOpacity, ), + ..._buildAudioWithCaption( + context, + message.attaches, + textColor, + isUltraOptimized, + messageTextOpacity, + ), ..._buildPhotosWithCaption( context, message.attaches, @@ -3514,6 +3704,7 @@ class _MessageContextMenu extends StatefulWidget { final VoidCallback? onForward; final bool canEditMessage; final bool hasUserReaction; + final bool isChannel; const _MessageContextMenu({ required this.message, @@ -3527,6 +3718,7 @@ class _MessageContextMenu extends StatefulWidget { this.onForward, required this.canEditMessage, required this.hasUserReaction, + this.isChannel = false, }); @override @@ -3790,7 +3982,7 @@ class _MessageContextMenuState extends State<_MessageContextMenu> text: 'Копировать', onTap: _onCopy, ), - if (widget.onReply != null) + if (widget.onReply != null && !widget.isChannel) _buildActionButton( icon: Icons.reply_rounded, text: 'Ответить', @@ -4126,3 +4318,632 @@ class _RotatingIconState extends State<_RotatingIcon> ); } } + +class _AudioPlayerWidget extends StatefulWidget { + final String url; + final int duration; + final String durationText; + final String? wave; + final int? audioId; + final Color textColor; + final BorderRadius borderRadius; + final double messageTextOpacity; + + const _AudioPlayerWidget({ + required this.url, + required this.duration, + required this.durationText, + this.wave, + this.audioId, + required this.textColor, + required this.borderRadius, + required this.messageTextOpacity, + }); + + @override + State<_AudioPlayerWidget> createState() => _AudioPlayerWidgetState(); +} + +class _AudioPlayerWidgetState extends State<_AudioPlayerWidget> { + late AudioPlayer _audioPlayer; + bool _isPlaying = false; + bool _isLoading = false; + bool _isCompleted = false; + Duration _position = Duration.zero; + Duration _totalDuration = Duration.zero; + List? _waveformData; + + @override + void initState() { + super.initState(); + _audioPlayer = AudioPlayer(); + _totalDuration = Duration(milliseconds: widget.duration); + + if (widget.wave != null && widget.wave!.isNotEmpty) { + _decodeWaveform(widget.wave!); + } + + if (widget.url.isNotEmpty) { + _preCacheAudio(); + } + + _audioPlayer.playerStateStream.listen((state) { + if (mounted) { + final wasCompleted = _isCompleted; + setState(() { + _isPlaying = state.playing; + _isLoading = + state.processingState == ProcessingState.loading || + state.processingState == ProcessingState.buffering; + _isCompleted = state.processingState == ProcessingState.completed; + }); + + if (state.processingState == ProcessingState.completed && + !wasCompleted) { + _audioPlayer.pause(); + } + } + }); + + _audioPlayer.positionStream.listen((position) { + if (mounted) { + final reachedEnd = + _totalDuration.inMilliseconds > 0 && + position.inMilliseconds >= _totalDuration.inMilliseconds - 50 && + _isPlaying; + + if (reachedEnd) { + _audioPlayer.pause(); + } + + setState(() { + _position = position; + if (reachedEnd) { + _isPlaying = false; + _isCompleted = true; + } + }); + } + }); + + _audioPlayer.durationStream.listen((duration) { + if (mounted && duration != null) { + setState(() { + _totalDuration = duration; + }); + } + }); + } + + void _decodeWaveform(String waveBase64) { + try { + String base64Data = waveBase64; + if (waveBase64.contains(',')) { + base64Data = waveBase64.split(',')[1]; + } + + final bytes = base64Decode(base64Data); + _waveformData = bytes.toList(); + } catch (e) { + print('Error decoding waveform: $e'); + _waveformData = null; + } + } + + Future _preCacheAudio() async { + try { + final cacheService = CacheService(); + final hasCached = await cacheService.hasCachedAudioFile( + widget.url, + customKey: widget.audioId?.toString(), + ); + if (!hasCached) { + print('Pre-caching audio: ${widget.url}'); + final cachedPath = await cacheService.cacheAudioFile( + widget.url, + customKey: widget.audioId?.toString(), + ); + if (cachedPath != null) { + print('Audio pre-cached successfully: $cachedPath'); + } else { + print('Failed to pre-cache audio (no internet?): ${widget.url}'); + } + } else { + print('Audio already cached: ${widget.url}'); + } + } catch (e) { + print('Error pre-caching audio: $e'); + } + } + + Future _togglePlayPause() async { + if (_isLoading) return; + + try { + if (_isPlaying) { + await _audioPlayer.pause(); + } else { + if (_isCompleted || + (_totalDuration.inMilliseconds > 0 && + _position.inMilliseconds >= + _totalDuration.inMilliseconds - 100)) { + await _audioPlayer.stop(); + await _audioPlayer.seek(Duration.zero); + if (mounted) { + setState(() { + _isCompleted = false; + _isPlaying = false; + _position = Duration.zero; + }); + } + await Future.delayed(const Duration(milliseconds: 150)); + } + + if (_audioPlayer.processingState == ProcessingState.idle) { + if (widget.url.isNotEmpty) { + final cacheService = CacheService(); + var cachedFile = await cacheService.getCachedAudioFile( + widget.url, + customKey: widget.audioId?.toString(), + ); + + if (cachedFile != null && await cachedFile.exists()) { + print('Using cached audio file: ${cachedFile.path}'); + await _audioPlayer.setFilePath(cachedFile.path); + } else { + print('Audio not cached, playing from URL: ${widget.url}'); + try { + await _audioPlayer.setUrl(widget.url); + + cacheService + .cacheAudioFile( + widget.url, + customKey: widget.audioId?.toString(), + ) + .then((cachedPath) { + if (cachedPath != null) { + print('Audio cached in background: $cachedPath'); + } else { + print('Failed to cache audio in background'); + } + }) + .catchError((e) { + print('Error caching audio in background: $e'); + }); + } catch (e) { + print('Error setting audio URL: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Не удалось загрузить аудио: ${e.toString()}', + ), + backgroundColor: Colors.red, + duration: const Duration(seconds: 3), + ), + ); + } + return; + } + } + } + } + await _audioPlayer.play(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка воспроизведения: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _seek(Duration position) async { + await _audioPlayer.seek(position); + if (mounted) { + setState(() { + _isCompleted = false; + }); + } + } + + String _formatDuration(Duration duration) { + final minutes = duration.inMinutes; + final seconds = duration.inSeconds % 60; + return '$minutes:${seconds.toString().padLeft(2, '0')}'; + } + + @override + void dispose() { + _audioPlayer.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final progress = _totalDuration.inMilliseconds > 0 + ? _position.inMilliseconds / _totalDuration.inMilliseconds + : 0.0; + + return Container( + decoration: BoxDecoration( + color: widget.textColor.withOpacity(0.05), + borderRadius: widget.borderRadius, + border: Border.all(color: widget.textColor.withOpacity(0.1), width: 1), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + GestureDetector( + onTap: _togglePlayPause, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: widget.textColor.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon( + _isPlaying ? Icons.pause : Icons.play_arrow, + color: widget.textColor.withOpacity( + 0.8 * widget.messageTextOpacity, + ), + size: 24, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (_waveformData != null && _waveformData!.isNotEmpty) + SizedBox( + height: 30, + child: CustomPaint( + painter: _WaveformPainter( + waveform: _waveformData!, + progress: progress, + color: widget.textColor.withOpacity( + 0.6 * widget.messageTextOpacity, + ), + progressColor: widget.textColor.withOpacity( + 0.9 * widget.messageTextOpacity, + ), + ), + child: GestureDetector( + onTapDown: (details) { + final RenderBox box = + context.findRenderObject() as RenderBox; + final localPosition = details.localPosition; + final tapProgress = + localPosition.dx / box.size.width; + final newPosition = Duration( + milliseconds: + (_totalDuration.inMilliseconds * tapProgress) + .round(), + ); + _seek(newPosition); + }, + ), + ), + ) + else + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: progress, + backgroundColor: widget.textColor.withOpacity(0.1), + valueColor: AlwaysStoppedAnimation( + widget.textColor.withOpacity( + 0.6 * widget.messageTextOpacity, + ), + ), + minHeight: 3, + ), + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _formatDuration(_position), + style: TextStyle( + color: widget.textColor.withOpacity( + 0.7 * widget.messageTextOpacity, + ), + fontSize: 12, + ), + ), + Text( + widget.durationText, + style: TextStyle( + color: widget.textColor.withOpacity( + 0.7 * widget.messageTextOpacity, + ), + fontSize: 12, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + final List waveform; + final double progress; + final Color color; + final Color progressColor; + + _WaveformPainter({ + required this.waveform, + required this.progress, + required this.color, + required this.progressColor, + }); + + @override + void paint(Canvas canvas, Size size) { + if (waveform.isEmpty) return; + + final paint = Paint() + ..strokeWidth = 2 + ..strokeCap = StrokeCap.round; + + final barWidth = size.width / waveform.length; + final maxAmplitude = waveform.reduce((a, b) => a > b ? a : b).toDouble(); + + for (int i = 0; i < waveform.length; i++) { + final amplitude = waveform[i].toDouble(); + final normalizedAmplitude = maxAmplitude > 0 + ? amplitude / maxAmplitude + : 0.0; + final barHeight = normalizedAmplitude * size.height * 0.8; + final x = i * barWidth + barWidth / 2; + final isPlayed = i / waveform.length < progress; + + paint.color = isPlayed ? progressColor : color; + + canvas.drawLine( + Offset(x, size.height / 2 - barHeight / 2), + Offset(x, size.height / 2 + barHeight / 2), + paint, + ); + } + } + + @override + bool shouldRepaint(_WaveformPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.waveform != waveform; + } +} + +class _VideoCirclePlayer extends StatefulWidget { + final int videoId; + final String messageId; + final int chatId; + final String? highQualityUrl; + final Uint8List? lowQualityBytes; + + const _VideoCirclePlayer({ + required this.videoId, + required this.messageId, + required this.chatId, + this.highQualityUrl, + this.lowQualityBytes, + }); + + @override + State<_VideoCirclePlayer> createState() => _VideoCirclePlayerState(); +} + +class _VideoCirclePlayerState extends State<_VideoCirclePlayer> { + VideoPlayerController? _controller; + bool _isLoading = true; + bool _hasError = false; + bool _isPlaying = false; + bool _isUserTapped = false; + + @override + void initState() { + super.initState(); + _loadVideo(); + } + + Future _loadVideo() async { + try { + final videoUrl = await ApiService.instance.getVideoUrl( + widget.videoId, + widget.chatId, + widget.messageId, + ); + + if (!mounted) return; + + _controller = VideoPlayerController.networkUrl( + Uri.parse(videoUrl), + httpHeaders: const { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + }, + ); + + await _controller!.initialize(); + + if (!mounted) return; + + _controller!.setLooping(true); + _controller!.setVolume(0.0); + _controller!.play(); + + setState(() { + _isLoading = false; + _isPlaying = true; + _isUserTapped = false; + }); + } catch (e) { + print('❌ [VideoCirclePlayer] Error loading video: $e'); + if (mounted) { + setState(() { + _hasError = true; + _isLoading = false; + }); + } + } + } + + void _videoListener() { + if (_controller == null || !_isUserTapped) return; + + if (_controller!.value.position >= _controller!.value.duration && + _controller!.value.duration > Duration.zero) { + _controller!.pause(); + _controller!.seekTo(Duration.zero); + if (mounted) { + setState(() { + _isPlaying = false; + }); + } + } + } + + void _togglePlayPause() { + if (_controller == null) return; + + if (!_isUserTapped) { + _controller!.addListener(_videoListener); + _controller!.setLooping(false); + _controller!.setVolume(1.0); + + _controller!.seekTo(Duration.zero); + + setState(() { + _isUserTapped = true; + _isPlaying = true; + }); + + _controller!.play(); + return; + } + + if (_isPlaying) { + _controller!.pause(); + setState(() { + _isPlaying = false; + }); + } else { + if (_controller!.value.position >= _controller!.value.duration) { + _controller!.seekTo(Duration.zero); + } + _controller!.play(); + setState(() { + _isPlaying = true; + }); + } + } + + @override + void dispose() { + _controller?.removeListener(_videoListener); + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: _togglePlayPause, + child: SizedBox( + width: 200, + height: 200, + child: ClipOval( + child: Stack( + alignment: Alignment.center, + fit: StackFit.expand, + children: [ + if (_isLoading || + _hasError || + _controller == null || + !_controller!.value.isInitialized) + (widget.highQualityUrl != null && + widget.highQualityUrl!.isNotEmpty) || + (widget.lowQualityBytes != null) + ? _ProgressiveNetworkImage( + url: widget.highQualityUrl ?? '', + previewBytes: widget.lowQualityBytes, + width: 200, + height: 200, + fit: BoxFit.cover, + keepAlive: false, + ) + : Container( + color: Colors.black26, + child: const Center( + child: Icon( + Icons.video_library_outlined, + color: Colors.white, + size: 40, + ), + ), + ) + else + VideoPlayer(_controller!), + + if (_isLoading) + Container( + color: Colors.black.withOpacity(0.3), + child: const Center( + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ), + ), + + if (!_isLoading && + !_hasError && + _controller != null && + _controller!.value.isInitialized) + AnimatedOpacity( + opacity: _isPlaying ? 0.0 : 0.8, + duration: const Duration(milliseconds: 200), + child: Container( + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.3), + shape: BoxShape.circle, + ), + child: Icon( + _isPlaying + ? Icons.pause_circle_filled + : Icons.play_circle_filled, + color: Colors.white, + size: 50, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/group_header.dart b/lib/widgets/group_header.dart index 2c34cd8..6873c14 100644 --- a/lib/widgets/group_header.dart +++ b/lib/widgets/group_header.dart @@ -26,7 +26,7 @@ class GroupHeader extends StatelessWidget { final colors = Theme.of(context).colorScheme; final onlineCount = chat.onlineParticipantsCount; - final totalCount = chat.participantsCount; + final totalCount = chat.participantsCount ?? chat.participantIds.length; return GestureDetector( onTap: () => _showGroupManagementPanel(context), @@ -38,7 +38,6 @@ class GroupHeader extends StatelessWidget { ), child: Row( children: [ - GroupAvatars( chat: chat, contacts: contacts, @@ -49,7 +48,6 @@ class GroupHeader extends StatelessWidget { const SizedBox(width: 12), - Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -67,7 +65,6 @@ class GroupHeader extends StatelessWidget { const SizedBox(height: 2), Row( children: [ - if (onlineCount > 0) ...[ Container( width: 8, @@ -89,7 +86,6 @@ class GroupHeader extends StatelessWidget { const SizedBox(width: 8), ], - Text( '$totalCount участников', style: TextStyle( diff --git a/lib/widgets/group_management_panel.dart b/lib/widgets/group_management_panel.dart index 12b8156..b120539 100644 --- a/lib/widgets/group_management_panel.dart +++ b/lib/widgets/group_management_panel.dart @@ -49,6 +49,8 @@ class _GroupManagementPanelState extends State { .where((contact) => contact != null) .cast() .toList(); + final totalParticipantsCount = + widget.chat.participantsCount ?? participantIds.length; return Container( decoration: BoxDecoration( @@ -57,7 +59,6 @@ class _GroupManagementPanelState extends State { ), child: Column( children: [ - Container( margin: const EdgeInsets.only(top: 8), width: 40, @@ -68,7 +69,6 @@ class _GroupManagementPanelState extends State { ), ), - Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -93,7 +93,7 @@ class _GroupManagementPanelState extends State { ), ), Text( - '${participants.length} участников', + '$totalParticipantsCount участников', style: TextStyle( fontSize: 14, color: colors.onSurfaceVariant, @@ -133,7 +133,6 @@ class _GroupManagementPanelState extends State { ), ), - Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: SizedBox( @@ -151,7 +150,6 @@ class _GroupManagementPanelState extends State { ), ), - Expanded( child: ListView.builder( controller: scrollController, @@ -349,10 +347,8 @@ class _GroupManagementPanelState extends State { int userId, { required bool cleanMessages, }) async { - print('Удаляем участника $userId, очистка сообщений: $cleanMessages'); - _apiService.sendMessage(widget.chat.id, '', replyToMessageId: null); } } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index f53d9bc..1a7d886 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,6 +6,7 @@ import FlutterMacOS import Foundation import app_links +import audio_session import device_info_plus import dynamic_color import file_picker @@ -13,6 +14,7 @@ import file_selector_macos import flutter_inappwebview_macos import flutter_secure_storage_macos import flutter_timezone +import just_audio import mobile_scanner import open_file_mac import package_info_plus @@ -27,6 +29,7 @@ import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) + AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) @@ -34,6 +37,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin")) + JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 50b3702..3b073f6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -65,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + audio_session: + dependency: transitive + description: + name: audio_session + sha256: "2b7fff16a552486d078bfc09a8cde19f426dc6d6329262b684182597bec5b1ac" + url: "https://pub.dev" + source: hosted + version: "0.1.25" boolean_selector: dependency: transitive description: @@ -701,6 +709,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.9.0" + just_audio: + dependency: "direct main" + description: + name: just_audio + sha256: f978d5b4ccea08f267dae0232ec5405c1b05d3f3cd63f82097ea46c015d5c09e + url: "https://pub.dev" + source: hosted + version: "0.9.46" + just_audio_platform_interface: + dependency: transitive + description: + name: just_audio_platform_interface + sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + just_audio_web: + dependency: transitive + description: + name: just_audio_web + sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663" + url: "https://pub.dev" + source: hosted + version: "0.4.16" leak_tracker: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e929262..14e6751 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -122,6 +122,8 @@ dependencies: chewie: ^1.7.5 + just_audio: ^0.9.40 + dev_dependencies: flutter_test: sdk: flutter