мультиаккаунгтинг(багованный, но он есть), избранное коректно отображается, убрана кнопка ответить в канале, добавлена поддержка видеокружков и голосовых сообщений
This commit is contained in:
@@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
import 'package:web_socket_channel/io.dart';
|
import 'package:web_socket_channel/io.dart';
|
||||||
import 'package:gwid/models/message.dart';
|
import 'package:gwid/models/message.dart';
|
||||||
import 'package:gwid/models/contact.dart';
|
import 'package:gwid/models/contact.dart';
|
||||||
|
import 'package:gwid/models/profile.dart';
|
||||||
import 'package:web_socket_channel/status.dart' as status;
|
import 'package:web_socket_channel/status.dart' as status;
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
@@ -12,6 +13,7 @@ import 'package:uuid/uuid.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:gwid/proxy_service.dart';
|
import 'package:gwid/proxy_service.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:gwid/services/account_manager.dart';
|
||||||
|
|
||||||
class ApiService {
|
class ApiService {
|
||||||
ApiService._privateConstructor();
|
ApiService._privateConstructor();
|
||||||
@@ -677,8 +679,7 @@ class ApiService {
|
|||||||
|
|
||||||
Future<Map<String, dynamic>> getChatsOnly({bool force = false}) async {
|
Future<Map<String, dynamic>> getChatsOnly({bool force = false}) async {
|
||||||
if (authToken == null) {
|
if (authToken == null) {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
await _loadTokenFromAccountManager();
|
||||||
authToken = prefs.getString('authToken');
|
|
||||||
}
|
}
|
||||||
if (authToken == null) throw Exception("Auth token not found");
|
if (authToken == null) throw Exception("Auth token not found");
|
||||||
|
|
||||||
@@ -985,6 +986,23 @@ class ApiService {
|
|||||||
final List<dynamic> chatListJson =
|
final List<dynamic> chatListJson =
|
||||||
chatResponse['payload']?['chats'] ?? [];
|
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 (chatListJson.isEmpty) {
|
||||||
if (config != null) {
|
if (config != null) {
|
||||||
_processServerPrivacyConfig(config);
|
_processServerPrivacyConfig(config);
|
||||||
@@ -1243,7 +1261,18 @@ class ApiService {
|
|||||||
Future<void> _sendInitialSetupRequests() async {
|
Future<void> _sendInitialSetupRequests() async {
|
||||||
print("Запускаем отправку единичных запросов при старте...");
|
print("Запускаем отправку единичных запросов при старте...");
|
||||||
|
|
||||||
|
if (!_isSessionOnline || !_isSessionReady) {
|
||||||
|
print("Сессия еще не готова, ждем...");
|
||||||
|
await waitUntilOnline();
|
||||||
|
}
|
||||||
|
|
||||||
await Future.delayed(const Duration(seconds: 2));
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
|
|
||||||
|
if (!_isSessionOnline || !_isSessionReady) {
|
||||||
|
print("Сессия не готова для отправки запросов, пропускаем");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_sendMessage(272, {"folderSync": 0});
|
_sendMessage(272, {"folderSync": 0});
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
_sendMessage(27, {"sync": 0, "type": "STICKER"});
|
_sendMessage(27, {"sync": 0, "type": "STICKER"});
|
||||||
@@ -1914,27 +1943,56 @@ class ApiService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveToken(String token, {String? userId}) async {
|
Future<void> saveToken(
|
||||||
|
String token, {
|
||||||
|
String? userId,
|
||||||
|
Profile? profile,
|
||||||
|
}) async {
|
||||||
print("Сохраняем новый токен: ${token.substring(0, 20)}...");
|
print("Сохраняем новый токен: ${token.substring(0, 20)}...");
|
||||||
if (userId != null) {
|
if (userId != null) {
|
||||||
print("Сохраняем UserID: $userId");
|
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;
|
authToken = token;
|
||||||
|
this.userId = userId;
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString('authToken', token);
|
await prefs.setString('authToken', token);
|
||||||
|
if (userId != null) {
|
||||||
|
await prefs.setString('userId', userId);
|
||||||
|
}
|
||||||
|
|
||||||
disconnect();
|
disconnect();
|
||||||
|
|
||||||
await connect();
|
await connect();
|
||||||
await getChatsAndContacts(force: true);
|
await getChatsAndContacts(force: true);
|
||||||
if (userId != null) {
|
print("Токен и UserID успешно сохранены");
|
||||||
await prefs.setString('userId', userId);
|
|
||||||
}
|
|
||||||
print("Токен и UserID успешно сохранены в SharedPreferences");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> hasToken() async {
|
Future<bool> hasToken() async {
|
||||||
if (authToken == null) {
|
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(
|
||||||
|
"Токен загружен из AccountManager: ${authToken!.substring(0, 20)}...",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Fallback на старый способ для обратной совместимости
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
authToken = prefs.getString('authToken');
|
authToken = prefs.getString('authToken');
|
||||||
userId = prefs.getString('userId');
|
userId = prefs.getString('userId');
|
||||||
@@ -1947,9 +2005,56 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return authToken != null;
|
return authToken != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadTokenFromAccountManager() async {
|
||||||
|
final accountManager = AccountManager();
|
||||||
|
await accountManager.initialize();
|
||||||
|
final currentAccount = accountManager.currentAccount;
|
||||||
|
if (currentAccount != null) {
|
||||||
|
authToken = currentAccount.token;
|
||||||
|
userId = currentAccount.userId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<List<Contact>> fetchContactsByIds(List<int> contactIds) async {
|
Future<List<Contact>> fetchContactsByIds(List<int> contactIds) async {
|
||||||
if (contactIds.isEmpty) {
|
if (contactIds.isEmpty) {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -110,6 +110,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final Map<String, GlobalKey> _messageKeys = {};
|
final Map<String, GlobalKey> _messageKeys = {};
|
||||||
|
|
||||||
void _checkContactCache() {
|
void _checkContactCache() {
|
||||||
|
if (widget.chatId == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
final cachedContact = ApiService.instance.getCachedContact(
|
final cachedContact = ApiService.instance.getCachedContact(
|
||||||
widget.contact.id,
|
widget.contact.id,
|
||||||
);
|
);
|
||||||
@@ -208,6 +211,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ApiService.instance.contactUpdates.listen((contact) {
|
ApiService.instance.contactUpdates.listen((contact) {
|
||||||
|
if (widget.chatId == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (contact.id == _currentContact.id && mounted) {
|
if (contact.id == _currentContact.id && mounted) {
|
||||||
ApiService.instance.updateCachedContact(contact);
|
ApiService.instance.updateCachedContact(contact);
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -1681,7 +1687,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
deferImageLoading: deferImageLoading,
|
deferImageLoading: deferImageLoading,
|
||||||
myUserId: _actualMyId,
|
myUserId: _actualMyId,
|
||||||
chatId: widget.chatId,
|
chatId: widget.chatId,
|
||||||
onReply: () => _replyToMessage(item.message),
|
onReply: widget.isChannel
|
||||||
|
? null
|
||||||
|
: () => _replyToMessage(item.message),
|
||||||
onForward: () => _forwardMessage(item.message),
|
onForward: () => _forwardMessage(item.message),
|
||||||
onEdit: isMe ? () => _editMessage(item.message) : null,
|
onEdit: isMe ? () => _editMessage(item.message) : null,
|
||||||
canEditMessage: isMe
|
canEditMessage: isMe
|
||||||
@@ -2059,7 +2067,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
onTap: _showContactProfile,
|
onTap: _showContactProfile,
|
||||||
child: Hero(
|
child: Hero(
|
||||||
tag: 'contact_avatar_${widget.contact.id}',
|
tag: 'contact_avatar_${widget.contact.id}',
|
||||||
child: CircleAvatar(
|
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,
|
radius: 18,
|
||||||
backgroundImage: widget.contact.photoBaseUrl != null
|
backgroundImage: widget.contact.photoBaseUrl != null
|
||||||
? NetworkImage(widget.contact.photoBaseUrl!)
|
? NetworkImage(widget.contact.photoBaseUrl!)
|
||||||
@@ -2135,7 +2155,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else if (widget.chatId != 0)
|
||||||
_ContactPresenceSubtitle(
|
_ContactPresenceSubtitle(
|
||||||
chatId: widget.chatId,
|
chatId: widget.chatId,
|
||||||
userId: widget.contact.id,
|
userId: widget.contact.id,
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import 'package:gwid/downloads_screen.dart';
|
|||||||
import 'package:gwid/user_id_lookup_screen.dart';
|
import 'package:gwid/user_id_lookup_screen.dart';
|
||||||
import 'package:gwid/widgets/message_preview_dialog.dart';
|
import 'package:gwid/widgets/message_preview_dialog.dart';
|
||||||
import 'package:gwid/services/chat_read_settings_service.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 {
|
class SearchResult {
|
||||||
final Chat chat;
|
final Chat chat;
|
||||||
@@ -90,6 +92,7 @@ class _ChatsScreenState extends State<ChatsScreen>
|
|||||||
String _connectionStatus = 'connecting';
|
String _connectionStatus = 'connecting';
|
||||||
StreamSubscription<void>? _connectionStatusSubscription;
|
StreamSubscription<void>? _connectionStatusSubscription;
|
||||||
StreamSubscription<String>? _connectionStateSubscription;
|
StreamSubscription<String>? _connectionStateSubscription;
|
||||||
|
bool _isAccountsExpanded = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -158,6 +161,21 @@ class _ChatsScreenState extends State<ChatsScreen>
|
|||||||
_isProfileLoading = true;
|
_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'];
|
final cachedProfileData = ApiService.instance.lastChatsPayload?['profile'];
|
||||||
if (cachedProfileData != null && mounted) {
|
if (cachedProfileData != null && mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -1734,10 +1752,19 @@ class _ChatsScreenState extends State<ChatsScreen>
|
|||||||
return Drawer(
|
return Drawer(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
FutureBuilder<List<Account>>(
|
||||||
|
future: _loadAccounts(),
|
||||||
|
builder: (context, accountsSnapshot) {
|
||||||
|
final accounts = accountsSnapshot.data ?? [];
|
||||||
|
final accountManager = AccountManager();
|
||||||
|
final currentAccount = accountManager.currentAccount;
|
||||||
|
final hasMultipleAccounts = accounts.length > 1;
|
||||||
|
|
||||||
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
|
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
top: MediaQuery.of(context).padding.top + 16.0,
|
top: MediaQuery.of(context).padding.top + 16.0,
|
||||||
left: 16.0,
|
left: 16.0,
|
||||||
@@ -1756,7 +1783,8 @@ class _ChatsScreenState extends State<ChatsScreen>
|
|||||||
radius: 30, // Чуть крупнее
|
radius: 30, // Чуть крупнее
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: colors.primary,
|
||||||
backgroundImage:
|
backgroundImage:
|
||||||
_isProfileLoading || _myProfile?.photoBaseUrl == null
|
_isProfileLoading ||
|
||||||
|
_myProfile?.photoBaseUrl == null
|
||||||
? null
|
? null
|
||||||
: NetworkImage(_myProfile!.photoBaseUrl!),
|
: NetworkImage(_myProfile!.photoBaseUrl!),
|
||||||
child: _isProfileLoading
|
child: _isProfileLoading
|
||||||
@@ -1770,7 +1798,10 @@ class _ChatsScreenState extends State<ChatsScreen>
|
|||||||
)
|
)
|
||||||
: (_myProfile?.photoBaseUrl == null
|
: (_myProfile?.photoBaseUrl == null
|
||||||
? Text(
|
? Text(
|
||||||
_myProfile?.displayName.isNotEmpty == true
|
_myProfile
|
||||||
|
?.displayName
|
||||||
|
.isNotEmpty ==
|
||||||
|
true
|
||||||
? _myProfile!.displayName[0]
|
? _myProfile!.displayName[0]
|
||||||
.toUpperCase()
|
.toUpperCase()
|
||||||
: '?',
|
: '?',
|
||||||
@@ -1792,7 +1823,9 @@ class _ChatsScreenState extends State<ChatsScreen>
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
themeProvider.toggleTheme();
|
themeProvider.toggleTheme();
|
||||||
},
|
},
|
||||||
tooltip: isDarkMode ? 'Светлая тема' : 'Темная тема',
|
tooltip: isDarkMode
|
||||||
|
? 'Светлая тема'
|
||||||
|
: 'Темная тема',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1808,20 +1841,170 @@ class _ChatsScreenState extends State<ChatsScreen>
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
|
||||||
Text(
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
_myProfile?.formattedPhone ?? '',
|
_myProfile?.formattedPhone ?? '',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colors.onPrimaryContainer.withOpacity(0.8),
|
color: colors.onPrimaryContainer.withOpacity(
|
||||||
|
0.8,
|
||||||
|
),
|
||||||
fontSize: 14,
|
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(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
_buildAccountsSection(context, colors),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.person_outline),
|
leading: const Icon(Icons.person_outline),
|
||||||
title: const Text('Мой профиль'),
|
title: const Text('Мой профиль'),
|
||||||
@@ -1905,6 +2088,16 @@ class _ChatsScreenState extends State<ChatsScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildAccountsSection(BuildContext context, ColorScheme colors) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Account>> _loadAccounts() async {
|
||||||
|
final accountManager = AccountManager();
|
||||||
|
await accountManager.initialize();
|
||||||
|
return accountManager.accounts;
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildSearchResults() {
|
Widget _buildSearchResults() {
|
||||||
final colors = Theme.of(context).colorScheme;
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
@@ -2002,10 +2195,23 @@ class _ChatsScreenState extends State<ChatsScreen>
|
|||||||
final participantCount =
|
final participantCount =
|
||||||
chat.participantsCount ?? chat.participantIds.length;
|
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) {
|
if (widget.onChatSelected != null) {
|
||||||
widget.onChatSelected!(
|
widget.onChatSelected!(
|
||||||
chat,
|
chat,
|
||||||
contact,
|
contactToUse,
|
||||||
isGroupChat,
|
isGroupChat,
|
||||||
isChannel,
|
isChannel,
|
||||||
participantCount,
|
participantCount,
|
||||||
@@ -2015,7 +2221,7 @@ class _ChatsScreenState extends State<ChatsScreen>
|
|||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => ChatScreen(
|
builder: (context) => ChatScreen(
|
||||||
chatId: chat.id,
|
chatId: chat.id,
|
||||||
contact: contact,
|
contact: contactToUse,
|
||||||
myId: chat.ownerId,
|
myId: chat.ownerId,
|
||||||
isGroupChat: isGroupChat,
|
isGroupChat: isGroupChat,
|
||||||
isChannel: isChannel,
|
isChannel: isChannel,
|
||||||
@@ -3587,8 +3793,18 @@ class _ChatsScreenState extends State<ChatsScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final Contact contactFallback =
|
final Contact contactFallback = isSavedMessages
|
||||||
contact ??
|
? Contact(
|
||||||
|
id: chat.id,
|
||||||
|
name: "Избранное",
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
photoBaseUrl: null,
|
||||||
|
description: null,
|
||||||
|
isBlocked: false,
|
||||||
|
isBlockedByMe: false,
|
||||||
|
)
|
||||||
|
: contact ??
|
||||||
Contact(
|
Contact(
|
||||||
id: chat.id,
|
id: chat.id,
|
||||||
name: title,
|
name: title,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:dynamic_color/dynamic_color.dart';
|
import 'package:dynamic_color/dynamic_color.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.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/avatar_cache_service.dart';
|
||||||
import 'services/chat_cache_service.dart';
|
import 'services/chat_cache_service.dart';
|
||||||
import 'services/version_checker.dart';
|
import 'services/version_checker.dart';
|
||||||
|
import 'services/account_manager.dart';
|
||||||
|
|
||||||
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||||
|
|
||||||
@@ -24,18 +23,20 @@ Future<void> main() async {
|
|||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await initializeDateFormatting();
|
await initializeDateFormatting();
|
||||||
|
|
||||||
|
|
||||||
print("Инициализируем сервисы кеширования...");
|
print("Инициализируем сервисы кеширования...");
|
||||||
await CacheService().initialize();
|
await CacheService().initialize();
|
||||||
await AvatarCacheService().initialize();
|
await AvatarCacheService().initialize();
|
||||||
await ChatCacheService().initialize();
|
await ChatCacheService().initialize();
|
||||||
print("Сервисы кеширования инициализированы");
|
print("Сервисы кеширования инициализированы");
|
||||||
|
|
||||||
|
print("Инициализируем AccountManager...");
|
||||||
|
await AccountManager().initialize();
|
||||||
|
await AccountManager().migrateOldAccount();
|
||||||
|
print("AccountManager инициализирован");
|
||||||
|
|
||||||
final hasToken = await ApiService.instance.hasToken();
|
final hasToken = await ApiService.instance.hasToken();
|
||||||
print("При запуске приложения токен ${hasToken ? 'найден' : 'не найден'}");
|
print("При запуске приложения токен ${hasToken ? 'найден' : 'не найден'}");
|
||||||
|
|
||||||
|
|
||||||
if (hasToken) {
|
if (hasToken) {
|
||||||
print("Инициируем подключение к WebSocket при запуске...");
|
print("Инициируем подключение к WebSocket при запуске...");
|
||||||
ApiService.instance.connect();
|
ApiService.instance.connect();
|
||||||
@@ -60,7 +61,6 @@ class MyApp extends StatelessWidget {
|
|||||||
|
|
||||||
return DynamicColorBuilder(
|
return DynamicColorBuilder(
|
||||||
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
|
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
|
||||||
|
|
||||||
final Color accentColor =
|
final Color accentColor =
|
||||||
(themeProvider.appTheme == AppTheme.system && lightDynamic != null)
|
(themeProvider.appTheme == AppTheme.system && lightDynamic != null)
|
||||||
? lightDynamic.primary
|
? lightDynamic.primary
|
||||||
@@ -167,7 +167,6 @@ class MyApp extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class _MiniFpsHud extends StatefulWidget {
|
class _MiniFpsHud extends StatefulWidget {
|
||||||
const _MiniFpsHud();
|
const _MiniFpsHud();
|
||||||
|
|
||||||
|
|||||||
105
lib/models/account.dart
Normal file
105
lib/models/account.dart
Normal file
@@ -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<String, dynamic> 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<String, dynamic> json) {
|
||||||
|
Profile? profile;
|
||||||
|
if (json['profile'] != null) {
|
||||||
|
final profileData = json['profile'] as Map<String, dynamic>;
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
|
|
||||||
|
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/material.dart';
|
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/api_service.dart';
|
||||||
import 'package:gwid/proxy_service.dart';
|
import 'package:gwid/proxy_service.dart';
|
||||||
import 'package:gwid/spoofing_service.dart';
|
import 'package:gwid/spoofing_service.dart';
|
||||||
@@ -73,16 +71,55 @@ class _ExportSessionScreenState extends State<ExportSessionScreen> {
|
|||||||
|
|
||||||
Uint8List bytes = Uint8List.fromList(utf8.encode(finalFileContent));
|
Uint8List bytes = Uint8List.fromList(utf8.encode(finalFileContent));
|
||||||
|
|
||||||
final Directory directory = await getDownloadsDirectory() ?? await getApplicationDocumentsDirectory();
|
final String fileName =
|
||||||
final String filePath = '${directory.path}/komet_session_${DateTime.now().millisecondsSinceEpoch}.ksession';
|
'komet_session_${DateTime.now().millisecondsSinceEpoch}.ksession';
|
||||||
|
|
||||||
final File file = File(filePath);
|
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);
|
await file.writeAsBytes(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outputFile == null) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(content: Text('Сохранение отменено')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
String displayPath = outputFile;
|
||||||
|
if (Platform.isAndroid || Platform.isIOS) {
|
||||||
|
displayPath = fileName;
|
||||||
|
}
|
||||||
|
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Файл сессии успешно сохранен: $filePath'),
|
content: Text('Файл сессии успешно сохранен: $displayPath'),
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -122,7 +159,6 @@ class _ExportSessionScreenState extends State<ExportSessionScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
|
||||||
Center(
|
Center(
|
||||||
child: CircleAvatar(
|
child: CircleAvatar(
|
||||||
radius: 40,
|
radius: 40,
|
||||||
@@ -154,7 +190,6 @@ class _ExportSessionScreenState extends State<ExportSessionScreen> {
|
|||||||
const Divider(),
|
const Divider(),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
'1. Защитите файл паролем',
|
'1. Защитите файл паролем',
|
||||||
style: textTheme.titleLarge?.copyWith(
|
style: textTheme.titleLarge?.copyWith(
|
||||||
@@ -214,7 +249,6 @@ class _ExportSessionScreenState extends State<ExportSessionScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -243,7 +277,6 @@ class _ExportSessionScreenState extends State<ExportSessionScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _isExporting ? null : _exportAndSaveSession,
|
onPressed: _isExporting ? null : _exportAndSaveSession,
|
||||||
icon: _isExporting
|
icon: _isExporting
|
||||||
|
|||||||
173
lib/services/account_manager.dart
Normal file
173
lib/services/account_manager.dart
Normal file
@@ -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<Account> _accounts = [];
|
||||||
|
|
||||||
|
Account? get currentAccount => _currentAccount;
|
||||||
|
List<Account> get accounts => List.unmodifiable(_accounts);
|
||||||
|
|
||||||
|
Future<void> initialize() async {
|
||||||
|
await _loadAccounts();
|
||||||
|
await _loadCurrentAccount();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadAccounts() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final accountsJson = prefs.getString(_accountsKey);
|
||||||
|
if (accountsJson != null) {
|
||||||
|
final List<dynamic> accountsList = jsonDecode(accountsJson);
|
||||||
|
_accounts = accountsList
|
||||||
|
.map((json) => Account.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('Ошибка загрузки аккаунтов: $e');
|
||||||
|
_accounts = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _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<void> _saveCurrentAccountId(String accountId) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_currentAccountIdKey, accountId);
|
||||||
|
} catch (e) {
|
||||||
|
print('Ошибка сохранения текущего аккаунта: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Account> 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<void> 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<void> 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<void> 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<void> 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
|
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
@@ -7,42 +5,41 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
|
||||||
class CacheService {
|
class CacheService {
|
||||||
static final CacheService _instance = CacheService._internal();
|
static final CacheService _instance = CacheService._internal();
|
||||||
factory CacheService() => _instance;
|
factory CacheService() => _instance;
|
||||||
CacheService._internal();
|
CacheService._internal();
|
||||||
|
|
||||||
|
|
||||||
final Map<String, dynamic> _memoryCache = {};
|
final Map<String, dynamic> _memoryCache = {};
|
||||||
final Map<String, DateTime> _cacheTimestamps = {};
|
final Map<String, DateTime> _cacheTimestamps = {};
|
||||||
|
|
||||||
|
|
||||||
static const Duration _defaultTTL = Duration(hours: 24);
|
static const Duration _defaultTTL = Duration(hours: 24);
|
||||||
static const int _maxMemoryCacheSize = 1000;
|
static const int _maxMemoryCacheSize = 1000;
|
||||||
|
|
||||||
|
|
||||||
SharedPreferences? _prefs;
|
SharedPreferences? _prefs;
|
||||||
|
|
||||||
|
|
||||||
Directory? _cacheDirectory;
|
Directory? _cacheDirectory;
|
||||||
|
|
||||||
|
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
_prefs = await SharedPreferences.getInstance();
|
_prefs = await SharedPreferences.getInstance();
|
||||||
_cacheDirectory = await getApplicationCacheDirectory();
|
_cacheDirectory = await getApplicationCacheDirectory();
|
||||||
|
|
||||||
|
|
||||||
await _createCacheDirectories();
|
await _createCacheDirectories();
|
||||||
|
|
||||||
print('CacheService инициализирован');
|
print('CacheService инициализирован');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<void> _createCacheDirectories() async {
|
Future<void> _createCacheDirectories() async {
|
||||||
if (_cacheDirectory == null) return;
|
if (_cacheDirectory == null) return;
|
||||||
|
|
||||||
final directories = ['avatars', 'images', 'files', 'chats', 'contacts'];
|
final directories = [
|
||||||
|
'avatars',
|
||||||
|
'images',
|
||||||
|
'files',
|
||||||
|
'chats',
|
||||||
|
'contacts',
|
||||||
|
'audio',
|
||||||
|
];
|
||||||
|
|
||||||
for (final dir in directories) {
|
for (final dir in directories) {
|
||||||
final directory = Directory('${_cacheDirectory!.path}/$dir');
|
final directory = Directory('${_cacheDirectory!.path}/$dir');
|
||||||
@@ -52,21 +49,17 @@ class CacheService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<T?> get<T>(String key, {Duration? ttl}) async {
|
Future<T?> get<T>(String key, {Duration? ttl}) async {
|
||||||
|
|
||||||
if (_memoryCache.containsKey(key)) {
|
if (_memoryCache.containsKey(key)) {
|
||||||
final timestamp = _cacheTimestamps[key];
|
final timestamp = _cacheTimestamps[key];
|
||||||
if (timestamp != null && !_isExpired(timestamp, ttl ?? _defaultTTL)) {
|
if (timestamp != null && !_isExpired(timestamp, ttl ?? _defaultTTL)) {
|
||||||
return _memoryCache[key] as T?;
|
return _memoryCache[key] as T?;
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
_memoryCache.remove(key);
|
_memoryCache.remove(key);
|
||||||
_cacheTimestamps.remove(key);
|
_cacheTimestamps.remove(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (_prefs != null) {
|
if (_prefs != null) {
|
||||||
try {
|
try {
|
||||||
final cacheKey = 'cache_$key';
|
final cacheKey = 'cache_$key';
|
||||||
@@ -80,7 +73,6 @@ class CacheService {
|
|||||||
final value = data['value'];
|
final value = data['value'];
|
||||||
|
|
||||||
if (!_isExpired(timestamp, ttl ?? _defaultTTL)) {
|
if (!_isExpired(timestamp, ttl ?? _defaultTTL)) {
|
||||||
|
|
||||||
_memoryCache[key] = value;
|
_memoryCache[key] = value;
|
||||||
_cacheTimestamps[key] = timestamp;
|
_cacheTimestamps[key] = timestamp;
|
||||||
return value as T?;
|
return value as T?;
|
||||||
@@ -94,20 +86,16 @@ class CacheService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<void> set<T>(String key, T value, {Duration? ttl}) async {
|
Future<void> set<T>(String key, T value, {Duration? ttl}) async {
|
||||||
final timestamp = DateTime.now();
|
final timestamp = DateTime.now();
|
||||||
|
|
||||||
|
|
||||||
_memoryCache[key] = value;
|
_memoryCache[key] = value;
|
||||||
_cacheTimestamps[key] = timestamp;
|
_cacheTimestamps[key] = timestamp;
|
||||||
|
|
||||||
|
|
||||||
if (_memoryCache.length > _maxMemoryCacheSize) {
|
if (_memoryCache.length > _maxMemoryCacheSize) {
|
||||||
await _evictOldestMemoryCache();
|
await _evictOldestMemoryCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (_prefs != null) {
|
if (_prefs != null) {
|
||||||
try {
|
try {
|
||||||
final cacheKey = 'cache_$key';
|
final cacheKey = 'cache_$key';
|
||||||
@@ -124,7 +112,6 @@ class CacheService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<void> remove(String key) async {
|
Future<void> remove(String key) async {
|
||||||
_memoryCache.remove(key);
|
_memoryCache.remove(key);
|
||||||
_cacheTimestamps.remove(key);
|
_cacheTimestamps.remove(key);
|
||||||
@@ -139,14 +126,12 @@ class CacheService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<void> clear() async {
|
Future<void> clear() async {
|
||||||
_memoryCache.clear();
|
_memoryCache.clear();
|
||||||
_cacheTimestamps.clear();
|
_cacheTimestamps.clear();
|
||||||
|
|
||||||
if (_prefs != null) {
|
if (_prefs != null) {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
final keys = _prefs!.getKeys().where((key) => key.startsWith('cache_'));
|
final keys = _prefs!.getKeys().where((key) => key.startsWith('cache_'));
|
||||||
for (final key in keys) {
|
for (final key in keys) {
|
||||||
await _prefs!.remove(key);
|
await _prefs!.remove(key);
|
||||||
@@ -156,7 +141,6 @@ class CacheService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (_cacheDirectory != null) {
|
if (_cacheDirectory != null) {
|
||||||
try {
|
try {
|
||||||
for (final dir in ['avatars', 'images', 'files', 'chats', 'contacts']) {
|
for (final dir in ['avatars', 'images', 'files', 'chats', 'contacts']) {
|
||||||
@@ -172,16 +156,13 @@ class CacheService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
bool _isExpired(DateTime timestamp, Duration ttl) {
|
bool _isExpired(DateTime timestamp, Duration ttl) {
|
||||||
return DateTime.now().difference(timestamp) > ttl;
|
return DateTime.now().difference(timestamp) > ttl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<void> _evictOldestMemoryCache() async {
|
Future<void> _evictOldestMemoryCache() async {
|
||||||
if (_memoryCache.isEmpty) return;
|
if (_memoryCache.isEmpty) return;
|
||||||
|
|
||||||
|
|
||||||
final sortedEntries = _cacheTimestamps.entries.toList()
|
final sortedEntries = _cacheTimestamps.entries.toList()
|
||||||
..sort((a, b) => a.value.compareTo(b.value));
|
..sort((a, b) => a.value.compareTo(b.value));
|
||||||
|
|
||||||
@@ -193,11 +174,9 @@ class CacheService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<Map<String, int>> getCacheSize() async {
|
Future<Map<String, int>> getCacheSize() async {
|
||||||
final memorySize = _memoryCache.length;
|
final memorySize = _memoryCache.length;
|
||||||
|
|
||||||
|
|
||||||
int filesSize = 0;
|
int filesSize = 0;
|
||||||
if (_cacheDirectory != null) {
|
if (_cacheDirectory != null) {
|
||||||
try {
|
try {
|
||||||
@@ -219,25 +198,20 @@ class CacheService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<String?> cacheFile(String url, {String? customKey}) async {
|
Future<String?> cacheFile(String url, {String? customKey}) async {
|
||||||
if (_cacheDirectory == null) return null;
|
if (_cacheDirectory == null) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
final fileName = _generateFileName(url, customKey);
|
final fileName = _generateFileName(url, customKey);
|
||||||
final filePath = '${_cacheDirectory!.path}/images/$fileName';
|
final filePath = '${_cacheDirectory!.path}/images/$fileName';
|
||||||
|
|
||||||
|
|
||||||
final existingFile = File(filePath);
|
final existingFile = File(filePath);
|
||||||
if (await existingFile.exists()) {
|
if (await existingFile.exists()) {
|
||||||
return filePath;
|
return filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
final response = await http.get(Uri.parse(url));
|
final response = await http.get(Uri.parse(url));
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
|
|
||||||
await existingFile.writeAsBytes(response.bodyBytes);
|
await existingFile.writeAsBytes(response.bodyBytes);
|
||||||
return filePath;
|
return filePath;
|
||||||
}
|
}
|
||||||
@@ -248,7 +222,6 @@ class CacheService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<File?> getCachedFile(String url, {String? customKey}) async {
|
Future<File?> getCachedFile(String url, {String? customKey}) async {
|
||||||
if (_cacheDirectory == null) return null;
|
if (_cacheDirectory == null) return null;
|
||||||
|
|
||||||
@@ -267,33 +240,42 @@ class CacheService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
String _generateFileName(String url, String? customKey) {
|
String _generateFileName(String url, String? customKey) {
|
||||||
final key = customKey ?? url;
|
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);
|
final extension = _getFileExtension(url);
|
||||||
return '$hash$extension';
|
return '$hash$extension';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
String _getFileExtension(String url) {
|
String _getFileExtension(String url) {
|
||||||
try {
|
try {
|
||||||
final uri = Uri.parse(url);
|
final uri = Uri.parse(url);
|
||||||
final path = uri.path;
|
final path = uri.path;
|
||||||
final extension = path.substring(path.lastIndexOf('.'));
|
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) {
|
} catch (e) {
|
||||||
return '.jpg';
|
return '.jpg';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<bool> hasCachedFile(String url, {String? customKey}) async {
|
Future<bool> hasCachedFile(String url, {String? customKey}) async {
|
||||||
final file = await getCachedFile(url, customKey: customKey);
|
final file = await getCachedFile(url, customKey: customKey);
|
||||||
return file != null;
|
return file != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<Map<String, dynamic>> getDetailedCacheStats() async {
|
Future<Map<String, dynamic>> getDetailedCacheStats() async {
|
||||||
final memorySize = _memoryCache.length;
|
final memorySize = _memoryCache.length;
|
||||||
final cacheSize = await getCacheSize();
|
final cacheSize = await getCacheSize();
|
||||||
@@ -308,12 +290,7 @@ class CacheService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> removeCachedFile(String url, {String? customKey}) async {}
|
||||||
Future<void> removeCachedFile(String url, {String? customKey}) async {
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Future<Map<String, dynamic>> getCacheStats() async {
|
Future<Map<String, dynamic>> getCacheStats() async {
|
||||||
final sizes = await getCacheSize();
|
final sizes = await getCacheSize();
|
||||||
@@ -329,4 +306,110 @@ class CacheService {
|
|||||||
'maxMemorySize': _maxMemoryCacheSize,
|
'maxMemorySize': _maxMemoryCacheSize,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<String?> 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<File?> 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<bool> hasCachedAudioFile(String url, {String? customKey}) async {
|
||||||
|
final file = await getCachedAudioFile(url, customKey: customKey);
|
||||||
|
return file != null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ import 'dart:async';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:open_file/open_file.dart';
|
import 'package:open_file/open_file.dart';
|
||||||
import 'package:gwid/full_screen_video_player.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;
|
bool _currentIsDark = false;
|
||||||
|
|
||||||
@@ -400,15 +403,30 @@ class ChatMessageBubble extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildVideoPreview({
|
Widget _buildVideoCirclePlayer({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
required int videoId,
|
required int videoId,
|
||||||
required String messageId,
|
required String messageId,
|
||||||
String? highQualityUrl,
|
String? highQualityUrl,
|
||||||
Uint8List? lowQualityBytes,
|
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 {
|
void openFullScreenVideo() async {
|
||||||
// Показываем индикатор загрузки, пока получаем URL
|
// Показываем индикатор загрузки, пока получаем 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(
|
return GestureDetector(
|
||||||
onTap: openFullScreenVideo,
|
onTap: openFullScreenVideo,
|
||||||
child: AspectRatio(
|
child: AspectRatio(
|
||||||
aspectRatio: 16 / 9,
|
aspectRatio: 16 / 9,
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: borderRadius,
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
// [!code ++] (НОВЫЙ БЛОК)
|
|
||||||
// Если у нас есть ХОТЬ ЧТО-ТО (блюр или URL), показываем ProgressiveImage
|
// Если у нас есть ХОТЬ ЧТО-ТО (блюр или URL), показываем ProgressiveImage
|
||||||
(highQualityUrl != null && highQualityUrl.isNotEmpty) ||
|
(highQualityUrl != null && highQualityUrl.isNotEmpty) ||
|
||||||
(lowQualityBytes != null)
|
(lowQualityBytes != null)
|
||||||
? _ProgressiveNetworkImage(
|
? _ProgressiveNetworkImage(
|
||||||
url:
|
url: highQualityUrl ?? '',
|
||||||
highQualityUrl ??
|
|
||||||
'', // _ProgressiveNetworkImage теперь это выдержит
|
|
||||||
previewBytes: lowQualityBytes,
|
previewBytes: lowQualityBytes,
|
||||||
width: 220,
|
width: 220,
|
||||||
height: 160,
|
height: 160,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
keepAlive: false,
|
keepAlive: false,
|
||||||
)
|
)
|
||||||
// ИНАЧЕ показываем нашу стандартную заглушку (а не пустоту)
|
|
||||||
: Container(
|
: Container(
|
||||||
color: Colors.black26,
|
color: Colors.black26,
|
||||||
child: const Center(
|
child: const Center(
|
||||||
@@ -481,12 +506,8 @@ class ChatMessageBubble extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// [!code ++] (КОНЕЦ НОВОГО БЛОКА)
|
|
||||||
|
|
||||||
// Иконка Play поверх (она будет поверх заглушки или картинки)
|
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
// Небольшое затемнение, чтобы иконка была виднее
|
|
||||||
color: Colors.black.withOpacity(0.15),
|
color: Colors.black.withOpacity(0.15),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
@@ -765,7 +786,7 @@ class ChatMessageBubble extends StatelessWidget {
|
|||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
],
|
],
|
||||||
// Действия с сообщением (остаются без изменений)
|
// Действия с сообщением (остаются без изменений)
|
||||||
if (onReply != null)
|
if (onReply != null && !isChannel)
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.reply),
|
leading: const Icon(Icons.reply),
|
||||||
title: const Text('Ответить'),
|
title: const Text('Ответить'),
|
||||||
@@ -871,6 +892,7 @@ class ChatMessageBubble extends StatelessWidget {
|
|||||||
onForward: onForward,
|
onForward: onForward,
|
||||||
canEditMessage: canEditMessage ?? false,
|
canEditMessage: canEditMessage ?? false,
|
||||||
hasUserReaction: hasUserReaction,
|
hasUserReaction: hasUserReaction,
|
||||||
|
isChannel: isChannel,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -947,6 +969,16 @@ class ChatMessageBubble extends StatelessWidget {
|
|||||||
return _buildStickerOnlyMessage(context);
|
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 hasUnsupportedContent = _hasUnsupportedMessageTypes();
|
||||||
|
|
||||||
final messageOpacity = themeProvider.messageBubbleOpacity;
|
final messageOpacity = themeProvider.messageBubbleOpacity;
|
||||||
@@ -1176,8 +1208,7 @@ class ChatMessageBubble extends StatelessWidget {
|
|||||||
bool _hasUnsupportedMessageTypes() {
|
bool _hasUnsupportedMessageTypes() {
|
||||||
final hasUnsupportedAttachments = message.attaches.any((attach) {
|
final hasUnsupportedAttachments = message.attaches.any((attach) {
|
||||||
final type = attach['_type']?.toString().toUpperCase();
|
final type = attach['_type']?.toString().toUpperCase();
|
||||||
return type == 'AUDIO' ||
|
return type == 'VOICE' ||
|
||||||
type == 'VOICE' ||
|
|
||||||
type == 'GIF' ||
|
type == 'GIF' ||
|
||||||
type == 'LOCATION' ||
|
type == 'LOCATION' ||
|
||||||
type == 'CONTACT';
|
type == 'CONTACT';
|
||||||
@@ -1334,7 +1365,7 @@ class ChatMessageBubble extends StatelessWidget {
|
|||||||
|
|
||||||
Widget _buildStickerOnlyMessage(BuildContext context) {
|
Widget _buildStickerOnlyMessage(BuildContext context) {
|
||||||
final sticker = message.attaches.firstWhere((a) => a['_type'] == 'STICKER');
|
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
|
final timeColor = Theme.of(context).brightness == Brightness.dark
|
||||||
? const Color(0xFF9bb5c7)
|
? 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(
|
Widget _buildStickerImage(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Map<String, dynamic> sticker,
|
Map<String, dynamic> sticker,
|
||||||
@@ -1479,6 +1601,7 @@ class ChatMessageBubble extends StatelessWidget {
|
|||||||
for (final video in videos) {
|
for (final video in videos) {
|
||||||
// 1. Извлекаем все, что нам нужно
|
// 1. Извлекаем все, что нам нужно
|
||||||
final videoId = video['videoId'] as int?;
|
final videoId = video['videoId'] as int?;
|
||||||
|
final videoType = video['videoType'] as int?;
|
||||||
final previewData = video['previewData'] as String?; // Блюр-превью
|
final previewData = video['previewData'] as String?; // Блюр-превью
|
||||||
final thumbnailUrl =
|
final thumbnailUrl =
|
||||||
video['url'] ?? video['baseUrl'] as String?; // HQ-превью URL
|
video['url'] ?? video['baseUrl'] as String?; // HQ-превью URL
|
||||||
@@ -1519,6 +1642,7 @@ class ChatMessageBubble extends StatelessWidget {
|
|||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
highQualityUrl: highQualityThumbnailUrl,
|
highQualityUrl: highQualityThumbnailUrl,
|
||||||
lowQualityBytes: previewBytes,
|
lowQualityBytes: previewBytes,
|
||||||
|
videoType: videoType,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1576,7 +1700,7 @@ class ChatMessageBubble extends StatelessWidget {
|
|||||||
bool isUltraOptimized,
|
bool isUltraOptimized,
|
||||||
) {
|
) {
|
||||||
// Стикеры обычно квадратные, около 200-250px
|
// Стикеры обычно квадратные, около 200-250px
|
||||||
final stickerSize = 250.0;
|
final stickerSize = 170.0;
|
||||||
|
|
||||||
return ConstrainedBox(
|
return ConstrainedBox(
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
@@ -1994,6 +2118,65 @@ class ChatMessageBubble extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildAudioWithCaption(
|
||||||
|
BuildContext context,
|
||||||
|
List<Map<String, dynamic>> attaches,
|
||||||
|
Color textColor,
|
||||||
|
bool isUltraOptimized,
|
||||||
|
double messageTextOpacity,
|
||||||
|
) {
|
||||||
|
final audioMessages = attaches.where((a) => a['_type'] == 'AUDIO').toList();
|
||||||
|
final List<Widget> 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<String, dynamic> 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<void> _handleFileDownload(
|
Future<void> _handleFileDownload(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
int? fileId,
|
int? fileId,
|
||||||
@@ -2830,6 +3013,13 @@ class ChatMessageBubble extends StatelessWidget {
|
|||||||
isUltraOptimized,
|
isUltraOptimized,
|
||||||
messageTextOpacity,
|
messageTextOpacity,
|
||||||
),
|
),
|
||||||
|
..._buildAudioWithCaption(
|
||||||
|
context,
|
||||||
|
message.attaches,
|
||||||
|
textColor,
|
||||||
|
isUltraOptimized,
|
||||||
|
messageTextOpacity,
|
||||||
|
),
|
||||||
..._buildPhotosWithCaption(
|
..._buildPhotosWithCaption(
|
||||||
context,
|
context,
|
||||||
message.attaches,
|
message.attaches,
|
||||||
@@ -3514,6 +3704,7 @@ class _MessageContextMenu extends StatefulWidget {
|
|||||||
final VoidCallback? onForward;
|
final VoidCallback? onForward;
|
||||||
final bool canEditMessage;
|
final bool canEditMessage;
|
||||||
final bool hasUserReaction;
|
final bool hasUserReaction;
|
||||||
|
final bool isChannel;
|
||||||
|
|
||||||
const _MessageContextMenu({
|
const _MessageContextMenu({
|
||||||
required this.message,
|
required this.message,
|
||||||
@@ -3527,6 +3718,7 @@ class _MessageContextMenu extends StatefulWidget {
|
|||||||
this.onForward,
|
this.onForward,
|
||||||
required this.canEditMessage,
|
required this.canEditMessage,
|
||||||
required this.hasUserReaction,
|
required this.hasUserReaction,
|
||||||
|
this.isChannel = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -3790,7 +3982,7 @@ class _MessageContextMenuState extends State<_MessageContextMenu>
|
|||||||
text: 'Копировать',
|
text: 'Копировать',
|
||||||
onTap: _onCopy,
|
onTap: _onCopy,
|
||||||
),
|
),
|
||||||
if (widget.onReply != null)
|
if (widget.onReply != null && !widget.isChannel)
|
||||||
_buildActionButton(
|
_buildActionButton(
|
||||||
icon: Icons.reply_rounded,
|
icon: Icons.reply_rounded,
|
||||||
text: 'Ответить',
|
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<int>? _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<void> _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<void> _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<void> _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<Color>(
|
||||||
|
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<int> 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<void> _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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class GroupHeader extends StatelessWidget {
|
|||||||
|
|
||||||
final colors = Theme.of(context).colorScheme;
|
final colors = Theme.of(context).colorScheme;
|
||||||
final onlineCount = chat.onlineParticipantsCount;
|
final onlineCount = chat.onlineParticipantsCount;
|
||||||
final totalCount = chat.participantsCount;
|
final totalCount = chat.participantsCount ?? chat.participantIds.length;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => _showGroupManagementPanel(context),
|
onTap: () => _showGroupManagementPanel(context),
|
||||||
@@ -38,7 +38,6 @@ class GroupHeader extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
|
||||||
GroupAvatars(
|
GroupAvatars(
|
||||||
chat: chat,
|
chat: chat,
|
||||||
contacts: contacts,
|
contacts: contacts,
|
||||||
@@ -49,7 +48,6 @@ class GroupHeader extends StatelessWidget {
|
|||||||
|
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -67,7 +65,6 @@ class GroupHeader extends StatelessWidget {
|
|||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
|
|
||||||
if (onlineCount > 0) ...[
|
if (onlineCount > 0) ...[
|
||||||
Container(
|
Container(
|
||||||
width: 8,
|
width: 8,
|
||||||
@@ -89,7 +86,6 @@ class GroupHeader extends StatelessWidget {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
'$totalCount участников',
|
'$totalCount участников',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ class _GroupManagementPanelState extends State<GroupManagementPanel> {
|
|||||||
.where((contact) => contact != null)
|
.where((contact) => contact != null)
|
||||||
.cast<Contact>()
|
.cast<Contact>()
|
||||||
.toList();
|
.toList();
|
||||||
|
final totalParticipantsCount =
|
||||||
|
widget.chat.participantsCount ?? participantIds.length;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -57,7 +59,6 @@ class _GroupManagementPanelState extends State<GroupManagementPanel> {
|
|||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
|
||||||
Container(
|
Container(
|
||||||
margin: const EdgeInsets.only(top: 8),
|
margin: const EdgeInsets.only(top: 8),
|
||||||
width: 40,
|
width: 40,
|
||||||
@@ -68,7 +69,6 @@ class _GroupManagementPanelState extends State<GroupManagementPanel> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -93,7 +93,7 @@ class _GroupManagementPanelState extends State<GroupManagementPanel> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'${participants.length} участников',
|
'$totalParticipantsCount участников',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: colors.onSurfaceVariant,
|
color: colors.onSurfaceVariant,
|
||||||
@@ -133,7 +133,6 @@ class _GroupManagementPanelState extends State<GroupManagementPanel> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
@@ -151,7 +150,6 @@ class _GroupManagementPanelState extends State<GroupManagementPanel> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
@@ -349,10 +347,8 @@ class _GroupManagementPanelState extends State<GroupManagementPanel> {
|
|||||||
int userId, {
|
int userId, {
|
||||||
required bool cleanMessages,
|
required bool cleanMessages,
|
||||||
}) async {
|
}) async {
|
||||||
|
|
||||||
print('Удаляем участника $userId, очистка сообщений: $cleanMessages');
|
print('Удаляем участника $userId, очистка сообщений: $cleanMessages');
|
||||||
|
|
||||||
|
|
||||||
_apiService.sendMessage(widget.chat.id, '', replyToMessageId: null);
|
_apiService.sendMessage(widget.chat.id, '', replyToMessageId: null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import FlutterMacOS
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
import app_links
|
import app_links
|
||||||
|
import audio_session
|
||||||
import device_info_plus
|
import device_info_plus
|
||||||
import dynamic_color
|
import dynamic_color
|
||||||
import file_picker
|
import file_picker
|
||||||
@@ -13,6 +14,7 @@ import file_selector_macos
|
|||||||
import flutter_inappwebview_macos
|
import flutter_inappwebview_macos
|
||||||
import flutter_secure_storage_macos
|
import flutter_secure_storage_macos
|
||||||
import flutter_timezone
|
import flutter_timezone
|
||||||
|
import just_audio
|
||||||
import mobile_scanner
|
import mobile_scanner
|
||||||
import open_file_mac
|
import open_file_mac
|
||||||
import package_info_plus
|
import package_info_plus
|
||||||
@@ -27,6 +29,7 @@ import wakelock_plus
|
|||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
|
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
|
||||||
|
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
|
||||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||||
DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin"))
|
DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin"))
|
||||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||||
@@ -34,6 +37,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
|
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
|
||||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||||
FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin"))
|
FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin"))
|
||||||
|
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
|
||||||
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
||||||
OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin"))
|
OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin"))
|
||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
|
|||||||
32
pubspec.lock
32
pubspec.lock
@@ -65,6 +65,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.13.0"
|
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:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -701,6 +709,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.9.0"
|
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:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ dependencies:
|
|||||||
|
|
||||||
chewie: ^1.7.5
|
chewie: ^1.7.5
|
||||||
|
|
||||||
|
just_audio: ^0.9.40
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|||||||
Reference in New Issue
Block a user