мультиаккаунгтинг(багованный, но он есть), избранное коректно отображается, убрана кнопка ответить в канале, добавлена поддержка видеокружков и голосовых сообщений

This commit is contained in:
needle10
2025-11-18 23:13:55 +03:00
parent e5b97208ad
commit 2d11f1cba2
14 changed files with 1803 additions and 218 deletions

View File

@@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:web_socket_channel/io.dart';
import 'package:gwid/models/message.dart';
import 'package:gwid/models/contact.dart';
import 'package:gwid/models/profile.dart';
import 'package:web_socket_channel/status.dart' as status;
import 'package:http/http.dart' as http;
import 'package:image_picker/image_picker.dart';
@@ -12,6 +13,7 @@ import 'package:uuid/uuid.dart';
import 'package:flutter/services.dart';
import 'package:gwid/proxy_service.dart';
import 'package:file_picker/file_picker.dart';
import 'package:gwid/services/account_manager.dart';
class ApiService {
ApiService._privateConstructor();
@@ -677,8 +679,7 @@ class ApiService {
Future<Map<String, dynamic>> getChatsOnly({bool force = false}) async {
if (authToken == null) {
final prefs = await SharedPreferences.getInstance();
authToken = prefs.getString('authToken');
await _loadTokenFromAccountManager();
}
if (authToken == null) throw Exception("Auth token not found");
@@ -985,6 +986,23 @@ class ApiService {
final List<dynamic> chatListJson =
chatResponse['payload']?['chats'] ?? [];
if (profile != null && authToken != null) {
try {
final accountManager = AccountManager();
await accountManager.initialize();
final currentAccount = accountManager.currentAccount;
if (currentAccount != null && currentAccount.token == authToken) {
final profileObj = Profile.fromJson(profile);
await accountManager.updateAccountProfile(
currentAccount.id,
profileObj,
);
}
} catch (e) {
print('Ошибка сохранения профиля в AccountManager: $e');
}
}
if (chatListJson.isEmpty) {
if (config != null) {
_processServerPrivacyConfig(config);
@@ -1243,7 +1261,18 @@ class ApiService {
Future<void> _sendInitialSetupRequests() async {
print("Запускаем отправку единичных запросов при старте...");
if (!_isSessionOnline || !_isSessionReady) {
print("Сессия еще не готова, ждем...");
await waitUntilOnline();
}
await Future.delayed(const Duration(seconds: 2));
if (!_isSessionOnline || !_isSessionReady) {
print("Сессия не готова для отправки запросов, пропускаем");
return;
}
_sendMessage(272, {"folderSync": 0});
await Future.delayed(const Duration(milliseconds: 500));
_sendMessage(27, {"sync": 0, "type": "STICKER"});
@@ -1914,42 +1943,118 @@ class ApiService {
});
}
Future<void> saveToken(String token, {String? userId}) async {
Future<void> saveToken(
String token, {
String? userId,
Profile? profile,
}) async {
print("Сохраняем новый токен: ${token.substring(0, 20)}...");
if (userId != null) {
print("Сохраняем UserID: $userId");
}
final accountManager = AccountManager();
await accountManager.initialize();
final account = await accountManager.addAccount(
token: token,
userId: userId,
profile: profile,
);
await accountManager.switchAccount(account.id);
authToken = token;
this.userId = userId;
final prefs = await SharedPreferences.getInstance();
await prefs.setString('authToken', token);
if (userId != null) {
await prefs.setString('userId', userId);
}
disconnect();
await connect();
await getChatsAndContacts(force: true);
if (userId != null) {
await prefs.setString('userId', userId);
}
print("Токен и UserID успешно сохранены в SharedPreferences");
print("Токен и UserID успешно сохранены");
}
Future<bool> hasToken() async {
if (authToken == null) {
final prefs = await SharedPreferences.getInstance();
authToken = prefs.getString('authToken');
userId = prefs.getString('userId');
if (authToken != null) {
final accountManager = AccountManager();
await accountManager.initialize();
await accountManager.migrateOldAccount();
final currentAccount = accountManager.currentAccount;
if (currentAccount != null) {
authToken = currentAccount.token;
userId = currentAccount.userId;
print(
"Токен загружен из SharedPreferences: ${authToken!.substring(0, 20)}...",
"Токен загружен из AccountManager: ${authToken!.substring(0, 20)}...",
);
if (userId != null) {
print("UserID загружен из SharedPreferences: $userId");
} else {
// Fallback на старый способ для обратной совместимости
final prefs = await SharedPreferences.getInstance();
authToken = prefs.getString('authToken');
userId = prefs.getString('userId');
if (authToken != null) {
print(
"Токен загружен из SharedPreferences: ${authToken!.substring(0, 20)}...",
);
if (userId != null) {
print("UserID загружен из SharedPreferences: $userId");
}
}
}
}
return authToken != null;
}
Future<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 {
if (contactIds.isEmpty) {
return [];

View File

@@ -110,6 +110,9 @@ class _ChatScreenState extends State<ChatScreen> {
final Map<String, GlobalKey> _messageKeys = {};
void _checkContactCache() {
if (widget.chatId == 0) {
return;
}
final cachedContact = ApiService.instance.getCachedContact(
widget.contact.id,
);
@@ -208,6 +211,9 @@ class _ChatScreenState extends State<ChatScreen> {
}
ApiService.instance.contactUpdates.listen((contact) {
if (widget.chatId == 0) {
return;
}
if (contact.id == _currentContact.id && mounted) {
ApiService.instance.updateCachedContact(contact);
setState(() {
@@ -1681,7 +1687,9 @@ class _ChatScreenState extends State<ChatScreen> {
deferImageLoading: deferImageLoading,
myUserId: _actualMyId,
chatId: widget.chatId,
onReply: () => _replyToMessage(item.message),
onReply: widget.isChannel
? null
: () => _replyToMessage(item.message),
onForward: () => _forwardMessage(item.message),
onEdit: isMe ? () => _editMessage(item.message) : null,
canEditMessage: isMe
@@ -2059,19 +2067,31 @@ class _ChatScreenState extends State<ChatScreen> {
onTap: _showContactProfile,
child: Hero(
tag: 'contact_avatar_${widget.contact.id}',
child: CircleAvatar(
radius: 18,
backgroundImage: widget.contact.photoBaseUrl != null
? NetworkImage(widget.contact.photoBaseUrl!)
: null,
child: widget.contact.photoBaseUrl == null
? Text(
widget.contact.name.isNotEmpty
? widget.contact.name[0].toUpperCase()
: '?',
)
: null,
),
child: widget.chatId == 0
? CircleAvatar(
radius: 18,
backgroundColor: Theme.of(
context,
).colorScheme.primaryContainer,
child: Icon(
Icons.bookmark,
size: 20,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
)
: CircleAvatar(
radius: 18,
backgroundImage: widget.contact.photoBaseUrl != null
? NetworkImage(widget.contact.photoBaseUrl!)
: null,
child: widget.contact.photoBaseUrl == null
? Text(
widget.contact.name.isNotEmpty
? widget.contact.name[0].toUpperCase()
: '?',
)
: null,
),
),
),
const SizedBox(width: 8),
@@ -2135,7 +2155,7 @@ class _ChatScreenState extends State<ChatScreen> {
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
)
else
else if (widget.chatId != 0)
_ContactPresenceSubtitle(
chatId: widget.chatId,
userId: widget.contact.id,

View File

@@ -27,6 +27,8 @@ import 'package:gwid/downloads_screen.dart';
import 'package:gwid/user_id_lookup_screen.dart';
import 'package:gwid/widgets/message_preview_dialog.dart';
import 'package:gwid/services/chat_read_settings_service.dart';
import 'package:gwid/services/account_manager.dart';
import 'package:gwid/models/account.dart';
class SearchResult {
final Chat chat;
@@ -90,6 +92,7 @@ class _ChatsScreenState extends State<ChatsScreen>
String _connectionStatus = 'connecting';
StreamSubscription<void>? _connectionStatusSubscription;
StreamSubscription<String>? _connectionStateSubscription;
bool _isAccountsExpanded = false;
@override
void initState() {
@@ -158,6 +161,21 @@ class _ChatsScreenState extends State<ChatsScreen>
_isProfileLoading = true;
});
try {
final accountManager = AccountManager();
await accountManager.initialize();
final currentAccount = accountManager.currentAccount;
if (currentAccount?.profile != null && mounted) {
setState(() {
_myProfile = currentAccount!.profile;
_isProfileLoading = false;
});
return;
}
} catch (e) {
print('Ошибка загрузки профиля из AccountManager: $e');
}
final cachedProfileData = ApiService.instance.lastChatsPayload?['profile'];
if (cachedProfileData != null && mounted) {
setState(() {
@@ -1735,93 +1753,258 @@ class _ChatsScreenState extends State<ChatsScreen>
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: double.infinity,
FutureBuilder<List<Account>>(
future: _loadAccounts(),
builder: (context, accountsSnapshot) {
final accounts = accountsSnapshot.data ?? [];
final accountManager = AccountManager();
final currentAccount = accountManager.currentAccount;
final hasMultipleAccounts = accounts.length > 1;
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + 16.0,
left: 16.0,
right: 16.0,
bottom: 16.0,
),
decoration: BoxDecoration(color: colors.primaryContainer),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 30, // Чуть крупнее
backgroundColor: colors.primary,
backgroundImage:
_isProfileLoading || _myProfile?.photoBaseUrl == null
? null
: NetworkImage(_myProfile!.photoBaseUrl!),
child: _isProfileLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
return Column(
children: [
Container(
width: double.infinity,
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + 16.0,
left: 16.0,
right: 16.0,
bottom: 16.0,
),
decoration: BoxDecoration(color: colors.primaryContainer),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 30, // Чуть крупнее
backgroundColor: colors.primary,
backgroundImage:
_isProfileLoading ||
_myProfile?.photoBaseUrl == null
? null
: NetworkImage(_myProfile!.photoBaseUrl!),
child: _isProfileLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: (_myProfile?.photoBaseUrl == null
? Text(
_myProfile
?.displayName
.isNotEmpty ==
true
? _myProfile!.displayName[0]
.toUpperCase()
: '?',
style: TextStyle(
color: colors.onPrimary,
fontSize: 28, // Крупнее
),
)
: null),
),
IconButton(
icon: Icon(
isDarkMode
? Icons.brightness_7
: Icons.brightness_4, // Солнце / Луна
color: colors.onPrimaryContainer,
size: 26,
),
onPressed: () {
themeProvider.toggleTheme();
},
tooltip: isDarkMode
? 'Светлая тема'
: 'Темная тема',
),
],
),
const SizedBox(height: 12),
Text(
_myProfile?.displayName ?? 'Загрузка...',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: colors.onPrimaryContainer,
),
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
_myProfile?.formattedPhone ?? '',
style: TextStyle(
color: colors.onPrimaryContainer.withOpacity(
0.8,
),
fontSize: 14,
),
),
),
InkWell(
onTap: () {
setState(() {
_isAccountsExpanded = !_isAccountsExpanded;
});
},
child: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Icon(
_isAccountsExpanded
? Icons.expand_less
: Icons.expand_more,
color: colors.onPrimaryContainer,
size: 24,
),
),
),
],
),
],
),
),
ClipRect(
child: AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOutCubic,
child: _isAccountsExpanded
? Column(
children: [
if (hasMultipleAccounts)
...accounts.map((account) {
final isCurrent =
account.id == currentAccount?.id;
return ListTile(
leading: CircleAvatar(
radius: 20,
backgroundColor: isCurrent
? colors.primary
: colors.surfaceVariant,
backgroundImage:
account.avatarUrl != null
? NetworkImage(account.avatarUrl!)
: null,
child: account.avatarUrl == null
? Text(
account.displayName.isNotEmpty
? account.displayName[0]
.toUpperCase()
: '?',
style: TextStyle(
color: isCurrent
? colors.onPrimary
: colors.onSurfaceVariant,
fontSize: 16,
),
)
: null,
),
title: Text(
account.displayName,
style: TextStyle(
fontWeight: isCurrent
? FontWeight.bold
: FontWeight.normal,
),
),
subtitle: account.displayPhone.isNotEmpty
? Text(account.displayPhone)
: null,
trailing: isCurrent
? Icon(
Icons.check_circle,
color: colors.primary,
size: 20,
)
: null,
onTap: isCurrent
? null
: () async {
Navigator.pop(context);
try {
await ApiService.instance
.switchAccount(account.id);
if (mounted) {
setState(() {
_isAccountsExpanded = false;
_loadMyProfile();
_chatsFuture = (() async {
try {
await ApiService
.instance
.waitUntilOnline();
return ApiService
.instance
.getChatsAndContacts();
} catch (e) {
print(
'Ошибка получения чатов: $e',
);
rethrow;
}
})();
});
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(
content: Text(
'Ошибка переключения аккаунта: $e',
),
backgroundColor:
colors.error,
),
);
}
}
},
);
}).toList(),
ListTile(
leading: const Icon(Icons.add_circle_outline),
title: const Text('Добавить аккаунт'),
onTap: () {
Navigator.pop(context);
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) =>
const PhoneEntryScreen(),
),
);
},
),
],
)
: (_myProfile?.photoBaseUrl == null
? Text(
_myProfile?.displayName.isNotEmpty == true
? _myProfile!.displayName[0]
.toUpperCase()
: '?',
style: TextStyle(
color: colors.onPrimary,
fontSize: 28, // Крупнее
),
)
: null),
: const SizedBox.shrink(),
),
IconButton(
icon: Icon(
isDarkMode
? Icons.brightness_7
: Icons.brightness_4, // Солнце / Луна
color: colors.onPrimaryContainer,
size: 26,
),
onPressed: () {
themeProvider.toggleTheme();
},
tooltip: isDarkMode ? 'Светлая тема' : 'Темная тема',
),
],
),
const SizedBox(height: 12),
Text(
_myProfile?.displayName ?? 'Загрузка...',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: colors.onPrimaryContainer,
),
),
const SizedBox(height: 4),
Text(
_myProfile?.formattedPhone ?? '',
style: TextStyle(
color: colors.onPrimaryContainer.withOpacity(0.8),
fontSize: 14,
),
),
],
),
],
);
},
),
Expanded(
child: Column(
children: [
_buildAccountsSection(context, colors),
ListTile(
leading: const Icon(Icons.person_outline),
title: const Text('Мой профиль'),
@@ -1905,6 +2088,16 @@ class _ChatsScreenState extends State<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() {
final colors = Theme.of(context).colorScheme;
@@ -2002,10 +2195,23 @@ class _ChatsScreenState extends State<ChatsScreen>
final participantCount =
chat.participantsCount ?? chat.participantIds.length;
final Contact contactToUse = isSavedMessages
? Contact(
id: chat.id,
name: "Избранное",
firstName: "",
lastName: "",
photoBaseUrl: null,
description: null,
isBlocked: false,
isBlockedByMe: false,
)
: contact;
if (widget.onChatSelected != null) {
widget.onChatSelected!(
chat,
contact,
contactToUse,
isGroupChat,
isChannel,
participantCount,
@@ -2015,7 +2221,7 @@ class _ChatsScreenState extends State<ChatsScreen>
MaterialPageRoute(
builder: (context) => ChatScreen(
chatId: chat.id,
contact: contact,
contact: contactToUse,
myId: chat.ownerId,
isGroupChat: isGroupChat,
isChannel: isChannel,
@@ -3587,18 +3793,28 @@ class _ChatsScreenState extends State<ChatsScreen>
}
}
final Contact contactFallback =
contact ??
Contact(
id: chat.id,
name: title,
firstName: "",
lastName: "",
photoBaseUrl: avatarUrl,
description: isChannel ? chat.description : null,
isBlocked: false,
isBlockedByMe: false,
);
final Contact contactFallback = isSavedMessages
? Contact(
id: chat.id,
name: "Избранное",
firstName: "",
lastName: "",
photoBaseUrl: null,
description: null,
isBlocked: false,
isBlockedByMe: false,
)
: contact ??
Contact(
id: chat.id,
name: title,
firstName: "",
lastName: "",
photoBaseUrl: avatarUrl,
description: isChannel ? chat.description : null,
isBlocked: false,
isBlockedByMe: false,
);
final participantCount =
chat.participantsCount ?? chat.participantIds.length;

View File

@@ -1,5 +1,3 @@
import 'package:flutter/material.dart';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
@@ -17,6 +15,7 @@ import 'services/cache_service.dart';
import 'services/avatar_cache_service.dart';
import 'services/chat_cache_service.dart';
import 'services/version_checker.dart';
import 'services/account_manager.dart';
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
@@ -24,18 +23,20 @@ Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await initializeDateFormatting();
print("Инициализируем сервисы кеширования...");
await CacheService().initialize();
await AvatarCacheService().initialize();
await ChatCacheService().initialize();
print("Сервисы кеширования инициализированы");
print("Инициализируем AccountManager...");
await AccountManager().initialize();
await AccountManager().migrateOldAccount();
print("AccountManager инициализирован");
final hasToken = await ApiService.instance.hasToken();
print("При запуске приложения токен ${hasToken ? 'найден' : 'не найден'}");
if (hasToken) {
print("Инициируем подключение к WebSocket при запуске...");
ApiService.instance.connect();
@@ -60,7 +61,6 @@ class MyApp extends StatelessWidget {
return DynamicColorBuilder(
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
final Color accentColor =
(themeProvider.appTheme == AppTheme.system && lightDynamic != null)
? lightDynamic.primary
@@ -167,7 +167,6 @@ class MyApp extends StatelessWidget {
}
}
class _MiniFpsHud extends StatefulWidget {
const _MiniFpsHud();

105
lib/models/account.dart Normal file
View 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,
);
}
}

View File

@@ -1,10 +1,8 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:file_picker/file_picker.dart';
import 'package:gwid/api_service.dart';
import 'package:gwid/proxy_service.dart';
import 'package:gwid/spoofing_service.dart';
@@ -73,16 +71,55 @@ class _ExportSessionScreenState extends State<ExportSessionScreen> {
Uint8List bytes = Uint8List.fromList(utf8.encode(finalFileContent));
final Directory directory = await getDownloadsDirectory() ?? await getApplicationDocumentsDirectory();
final String filePath = '${directory.path}/komet_session_${DateTime.now().millisecondsSinceEpoch}.ksession';
final File file = File(filePath);
await file.writeAsBytes(bytes);
final String fileName =
'komet_session_${DateTime.now().millisecondsSinceEpoch}.ksession';
String? outputFile;
if (Platform.isAndroid || Platform.isIOS) {
outputFile = await FilePicker.platform.saveFile(
dialogTitle: 'Сохранить файл сессии...',
fileName: fileName,
allowedExtensions: ['ksession'],
type: FileType.custom,
bytes: bytes,
);
} else {
outputFile = await FilePicker.platform.saveFile(
dialogTitle: 'Сохранить файл сессии...',
fileName: fileName,
allowedExtensions: ['ksession'],
type: FileType.custom,
);
if (outputFile != null) {
if (!outputFile.endsWith('.ksession')) {
outputFile += '.ksession';
}
final File file = File(outputFile);
await file.writeAsBytes(bytes);
}
}
if (outputFile == null) {
if (mounted) {
messenger.showSnackBar(
const SnackBar(content: Text('Сохранение отменено')),
);
}
return;
}
if (mounted) {
String displayPath = outputFile;
if (Platform.isAndroid || Platform.isIOS) {
displayPath = fileName;
}
messenger.showSnackBar(
SnackBar(
content: Text('Файл сессии успешно сохранен: $filePath'),
content: Text('Файл сессии успешно сохранен: $displayPath'),
backgroundColor: Colors.green,
),
);
@@ -122,7 +159,6 @@ class _ExportSessionScreenState extends State<ExportSessionScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: CircleAvatar(
radius: 40,
@@ -154,7 +190,6 @@ class _ExportSessionScreenState extends State<ExportSessionScreen> {
const Divider(),
const SizedBox(height: 24),
Text(
'1. Защитите файл паролем',
style: textTheme.titleLarge?.copyWith(
@@ -214,7 +249,6 @@ class _ExportSessionScreenState extends State<ExportSessionScreen> {
),
const SizedBox(height: 32),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
@@ -243,7 +277,6 @@ class _ExportSessionScreenState extends State<ExportSessionScreen> {
),
const SizedBox(height: 32),
FilledButton.icon(
onPressed: _isExporting ? null : _exportAndSaveSession,
icon: _isExporting

View 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');
}
}
}

View File

@@ -1,5 +1,3 @@
import 'dart:async';
import 'dart:io';
import 'dart:convert';
@@ -7,42 +5,41 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:path_provider/path_provider.dart';
import 'package:http/http.dart' as http;
class CacheService {
static final CacheService _instance = CacheService._internal();
factory CacheService() => _instance;
CacheService._internal();
final Map<String, dynamic> _memoryCache = {};
final Map<String, DateTime> _cacheTimestamps = {};
static const Duration _defaultTTL = Duration(hours: 24);
static const int _maxMemoryCacheSize = 1000;
SharedPreferences? _prefs;
Directory? _cacheDirectory;
Future<void> initialize() async {
_prefs = await SharedPreferences.getInstance();
_cacheDirectory = await getApplicationCacheDirectory();
await _createCacheDirectories();
print('CacheService инициализирован');
}
Future<void> _createCacheDirectories() async {
if (_cacheDirectory == null) return;
final directories = ['avatars', 'images', 'files', 'chats', 'contacts'];
final directories = [
'avatars',
'images',
'files',
'chats',
'contacts',
'audio',
];
for (final dir in directories) {
final directory = Directory('${_cacheDirectory!.path}/$dir');
@@ -52,21 +49,17 @@ class CacheService {
}
}
Future<T?> get<T>(String key, {Duration? ttl}) async {
if (_memoryCache.containsKey(key)) {
final timestamp = _cacheTimestamps[key];
if (timestamp != null && !_isExpired(timestamp, ttl ?? _defaultTTL)) {
return _memoryCache[key] as T?;
} else {
_memoryCache.remove(key);
_cacheTimestamps.remove(key);
}
}
if (_prefs != null) {
try {
final cacheKey = 'cache_$key';
@@ -80,7 +73,6 @@ class CacheService {
final value = data['value'];
if (!_isExpired(timestamp, ttl ?? _defaultTTL)) {
_memoryCache[key] = value;
_cacheTimestamps[key] = timestamp;
return value as T?;
@@ -94,20 +86,16 @@ class CacheService {
return null;
}
Future<void> set<T>(String key, T value, {Duration? ttl}) async {
final timestamp = DateTime.now();
_memoryCache[key] = value;
_cacheTimestamps[key] = timestamp;
if (_memoryCache.length > _maxMemoryCacheSize) {
await _evictOldestMemoryCache();
}
if (_prefs != null) {
try {
final cacheKey = 'cache_$key';
@@ -124,7 +112,6 @@ class CacheService {
}
}
Future<void> remove(String key) async {
_memoryCache.remove(key);
_cacheTimestamps.remove(key);
@@ -139,14 +126,12 @@ class CacheService {
}
}
Future<void> clear() async {
_memoryCache.clear();
_cacheTimestamps.clear();
if (_prefs != null) {
try {
final keys = _prefs!.getKeys().where((key) => key.startsWith('cache_'));
for (final key in keys) {
await _prefs!.remove(key);
@@ -156,7 +141,6 @@ class CacheService {
}
}
if (_cacheDirectory != null) {
try {
for (final dir in ['avatars', 'images', 'files', 'chats', 'contacts']) {
@@ -172,16 +156,13 @@ class CacheService {
}
}
bool _isExpired(DateTime timestamp, Duration ttl) {
return DateTime.now().difference(timestamp) > ttl;
}
Future<void> _evictOldestMemoryCache() async {
if (_memoryCache.isEmpty) return;
final sortedEntries = _cacheTimestamps.entries.toList()
..sort((a, b) => a.value.compareTo(b.value));
@@ -193,11 +174,9 @@ class CacheService {
}
}
Future<Map<String, int>> getCacheSize() async {
final memorySize = _memoryCache.length;
int filesSize = 0;
if (_cacheDirectory != null) {
try {
@@ -219,25 +198,20 @@ class CacheService {
};
}
Future<String?> cacheFile(String url, {String? customKey}) async {
if (_cacheDirectory == null) return null;
try {
final fileName = _generateFileName(url, customKey);
final filePath = '${_cacheDirectory!.path}/images/$fileName';
final existingFile = File(filePath);
if (await existingFile.exists()) {
return filePath;
}
final response = await http.get(Uri.parse(url));
if (response.statusCode == 200) {
await existingFile.writeAsBytes(response.bodyBytes);
return filePath;
}
@@ -248,7 +222,6 @@ class CacheService {
return null;
}
Future<File?> getCachedFile(String url, {String? customKey}) async {
if (_cacheDirectory == null) return null;
@@ -267,33 +240,42 @@ class CacheService {
return null;
}
String _generateFileName(String url, String? customKey) {
final key = customKey ?? url;
final hash = key.hashCode.abs().toString().substring(0, 16);
final hashString = key.hashCode.abs().toString();
final hash = hashString.length >= 16
? hashString.substring(0, 16)
: hashString.padRight(16, '0');
final extension = _getFileExtension(url);
return '$hash$extension';
}
String _getFileExtension(String url) {
try {
final uri = Uri.parse(url);
final path = uri.path;
final extension = path.substring(path.lastIndexOf('.'));
return extension.isNotEmpty && extension.length < 10 ? extension : '.jpg';
if (extension.isNotEmpty && extension.length < 10) {
return extension;
}
if (url.contains('audio') ||
url.contains('voice') ||
url.contains('.mp3') ||
url.contains('.ogg') ||
url.contains('.m4a')) {
return '.mp3';
}
return '.jpg';
} catch (e) {
return '.jpg';
}
}
Future<bool> hasCachedFile(String url, {String? customKey}) async {
final file = await getCachedFile(url, customKey: customKey);
return file != null;
}
Future<Map<String, dynamic>> getDetailedCacheStats() async {
final memorySize = _memoryCache.length;
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 {
final sizes = await getCacheSize();
@@ -329,4 +306,110 @@ class CacheService {
'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;
}
}

View File

@@ -22,6 +22,9 @@ import 'dart:async';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:open_file/open_file.dart';
import 'package:gwid/full_screen_video_player.dart';
import 'package:just_audio/just_audio.dart';
import 'package:gwid/services/cache_service.dart';
import 'package:video_player/video_player.dart';
bool _currentIsDark = false;
@@ -400,15 +403,30 @@ class ChatMessageBubble extends StatelessWidget {
);
}
Widget _buildVideoPreview({
Widget _buildVideoCirclePlayer({
required BuildContext context,
required int videoId,
required String messageId,
String? highQualityUrl,
Uint8List? lowQualityBytes,
}) {
final borderRadius = BorderRadius.circular(12);
return _VideoCirclePlayer(
videoId: videoId,
messageId: messageId,
chatId: chatId!,
highQualityUrl: highQualityUrl,
lowQualityBytes: lowQualityBytes,
);
}
Widget _buildVideoPreview({
required BuildContext context,
required int videoId,
required String messageId,
String? highQualityUrl,
Uint8List? lowQualityBytes,
int? videoType,
}) {
// Логика открытия плеера
void openFullScreenVideo() async {
// Показываем индикатор загрузки, пока получаем URL
@@ -445,32 +463,39 @@ class ChatMessageBubble extends StatelessWidget {
}
}
// Виджет-контейнер (GestureDetector + Stack)
final isVideoCircle = videoType == 1;
if (isVideoCircle) {
return _buildVideoCirclePlayer(
context: context,
videoId: videoId,
messageId: messageId,
highQualityUrl: highQualityUrl,
lowQualityBytes: lowQualityBytes,
);
}
return GestureDetector(
onTap: openFullScreenVideo,
child: AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius: borderRadius,
borderRadius: BorderRadius.circular(12),
child: Stack(
alignment: Alignment.center,
fit: StackFit.expand,
children: [
// [!code ++] (НОВЫЙ БЛОК)
// Если у нас есть ХОТЬ ЧТО-ТО (блюр или URL), показываем ProgressiveImage
(highQualityUrl != null && highQualityUrl.isNotEmpty) ||
(lowQualityBytes != null)
? _ProgressiveNetworkImage(
url:
highQualityUrl ??
'', // _ProgressiveNetworkImage теперь это выдержит
url: highQualityUrl ?? '',
previewBytes: lowQualityBytes,
width: 220,
height: 160,
fit: BoxFit.cover,
keepAlive: false,
)
// ИНАЧЕ показываем нашу стандартную заглушку (а не пустоту)
: Container(
color: Colors.black26,
child: const Center(
@@ -481,12 +506,8 @@ class ChatMessageBubble extends StatelessWidget {
),
),
),
// [!code ++] (КОНЕЦ НОВОГО БЛОКА)
// Иконка Play поверх (она будет поверх заглушки или картинки)
Container(
decoration: BoxDecoration(
// Небольшое затемнение, чтобы иконка была виднее
color: Colors.black.withOpacity(0.15),
),
child: Icon(
@@ -765,7 +786,7 @@ class ChatMessageBubble extends StatelessWidget {
const Divider(height: 1),
],
// Действия с сообщением (остаются без изменений)
if (onReply != null)
if (onReply != null && !isChannel)
ListTile(
leading: const Icon(Icons.reply),
title: const Text('Ответить'),
@@ -871,6 +892,7 @@ class ChatMessageBubble extends StatelessWidget {
onForward: onForward,
canEditMessage: canEditMessage ?? false,
hasUserReaction: hasUserReaction,
isChannel: isChannel,
);
},
);
@@ -947,6 +969,16 @@ class ChatMessageBubble extends StatelessWidget {
return _buildStickerOnlyMessage(context);
}
final isVideoCircle =
message.attaches.length == 1 &&
message.attaches.any(
(a) => a['_type'] == 'VIDEO' && (a['videoType'] as int?) == 1,
) &&
message.text.isEmpty;
if (isVideoCircle) {
return _buildVideoCircleOnlyMessage(context);
}
final hasUnsupportedContent = _hasUnsupportedMessageTypes();
final messageOpacity = themeProvider.messageBubbleOpacity;
@@ -1176,8 +1208,7 @@ class ChatMessageBubble extends StatelessWidget {
bool _hasUnsupportedMessageTypes() {
final hasUnsupportedAttachments = message.attaches.any((attach) {
final type = attach['_type']?.toString().toUpperCase();
return type == 'AUDIO' ||
type == 'VOICE' ||
return type == 'VOICE' ||
type == 'GIF' ||
type == 'LOCATION' ||
type == 'CONTACT';
@@ -1334,7 +1365,7 @@ class ChatMessageBubble extends StatelessWidget {
Widget _buildStickerOnlyMessage(BuildContext context) {
final sticker = message.attaches.firstWhere((a) => a['_type'] == 'STICKER');
final stickerSize = 250.0;
final stickerSize = 170.0;
final timeColor = Theme.of(context).brightness == Brightness.dark
? const Color(0xFF9bb5c7)
@@ -1397,6 +1428,97 @@ class ChatMessageBubble extends StatelessWidget {
);
}
Widget _buildVideoCircleOnlyMessage(BuildContext context) {
final video = message.attaches.firstWhere((a) => a['_type'] == 'VIDEO');
final videoId = video['videoId'] as int?;
final previewData = video['previewData'] as String?;
final thumbnailUrl = video['url'] ?? video['baseUrl'] as String?;
Uint8List? previewBytes;
if (previewData != null && previewData.startsWith('data:')) {
final idx = previewData.indexOf('base64,');
if (idx != -1) {
final b64 = previewData.substring(idx + 7);
try {
previewBytes = base64Decode(b64);
} catch (_) {}
}
}
String? highQualityThumbnailUrl;
if (thumbnailUrl != null && thumbnailUrl.isNotEmpty) {
highQualityThumbnailUrl = thumbnailUrl;
if (!thumbnailUrl.contains('?')) {
highQualityThumbnailUrl =
'$thumbnailUrl?size=medium&quality=high&format=jpeg';
} else {
highQualityThumbnailUrl =
'$thumbnailUrl&size=medium&quality=high&format=jpeg';
}
}
final timeColor = Theme.of(context).brightness == Brightness.dark
? const Color(0xFF9bb5c7)
: const Color(0xFF6b7280);
Widget videoContent = Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
child: Column(
crossAxisAlignment: isMe
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: isMe
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Column(
crossAxisAlignment: isMe
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
if (videoId != null && chatId != null)
_buildVideoCirclePlayer(
context: context,
videoId: videoId,
messageId: message.id,
highQualityUrl: highQualityThumbnailUrl,
lowQualityBytes: previewBytes,
),
Padding(
padding: const EdgeInsets.only(top: 4, right: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatMessageTime(context, message.time),
style: TextStyle(fontSize: 12, color: timeColor),
),
],
),
),
],
),
],
),
],
),
);
if (onReaction != null || (isMe && (onEdit != null || onDelete != null))) {
videoContent = GestureDetector(
onTapDown: (TapDownDetails details) {
_showMessageContextMenu(context, details.globalPosition);
},
child: videoContent,
);
}
return videoContent;
}
Widget _buildStickerImage(
BuildContext context,
Map<String, dynamic> sticker,
@@ -1479,6 +1601,7 @@ class ChatMessageBubble extends StatelessWidget {
for (final video in videos) {
// 1. Извлекаем все, что нам нужно
final videoId = video['videoId'] as int?;
final videoType = video['videoType'] as int?;
final previewData = video['previewData'] as String?; // Блюр-превью
final thumbnailUrl =
video['url'] ?? video['baseUrl'] as String?; // HQ-превью URL
@@ -1519,6 +1642,7 @@ class ChatMessageBubble extends StatelessWidget {
messageId: message.id,
highQualityUrl: highQualityThumbnailUrl,
lowQualityBytes: previewBytes,
videoType: videoType,
),
),
);
@@ -1576,7 +1700,7 @@ class ChatMessageBubble extends StatelessWidget {
bool isUltraOptimized,
) {
// Стикеры обычно квадратные, около 200-250px
final stickerSize = 250.0;
final stickerSize = 170.0;
return ConstrainedBox(
constraints: BoxConstraints(
@@ -1994,6 +2118,65 @@ class ChatMessageBubble extends StatelessWidget {
}
}
List<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(
BuildContext context,
int? fileId,
@@ -2830,6 +3013,13 @@ class ChatMessageBubble extends StatelessWidget {
isUltraOptimized,
messageTextOpacity,
),
..._buildAudioWithCaption(
context,
message.attaches,
textColor,
isUltraOptimized,
messageTextOpacity,
),
..._buildPhotosWithCaption(
context,
message.attaches,
@@ -3514,6 +3704,7 @@ class _MessageContextMenu extends StatefulWidget {
final VoidCallback? onForward;
final bool canEditMessage;
final bool hasUserReaction;
final bool isChannel;
const _MessageContextMenu({
required this.message,
@@ -3527,6 +3718,7 @@ class _MessageContextMenu extends StatefulWidget {
this.onForward,
required this.canEditMessage,
required this.hasUserReaction,
this.isChannel = false,
});
@override
@@ -3790,7 +3982,7 @@ class _MessageContextMenuState extends State<_MessageContextMenu>
text: 'Копировать',
onTap: _onCopy,
),
if (widget.onReply != null)
if (widget.onReply != null && !widget.isChannel)
_buildActionButton(
icon: Icons.reply_rounded,
text: 'Ответить',
@@ -4126,3 +4318,632 @@ class _RotatingIconState extends State<_RotatingIcon>
);
}
}
class _AudioPlayerWidget extends StatefulWidget {
final String url;
final int duration;
final String durationText;
final String? wave;
final int? audioId;
final Color textColor;
final BorderRadius borderRadius;
final double messageTextOpacity;
const _AudioPlayerWidget({
required this.url,
required this.duration,
required this.durationText,
this.wave,
this.audioId,
required this.textColor,
required this.borderRadius,
required this.messageTextOpacity,
});
@override
State<_AudioPlayerWidget> createState() => _AudioPlayerWidgetState();
}
class _AudioPlayerWidgetState extends State<_AudioPlayerWidget> {
late AudioPlayer _audioPlayer;
bool _isPlaying = false;
bool _isLoading = false;
bool _isCompleted = false;
Duration _position = Duration.zero;
Duration _totalDuration = Duration.zero;
List<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,
),
),
),
],
),
),
),
);
}
}

View File

@@ -26,7 +26,7 @@ class GroupHeader extends StatelessWidget {
final colors = Theme.of(context).colorScheme;
final onlineCount = chat.onlineParticipantsCount;
final totalCount = chat.participantsCount;
final totalCount = chat.participantsCount ?? chat.participantIds.length;
return GestureDetector(
onTap: () => _showGroupManagementPanel(context),
@@ -38,7 +38,6 @@ class GroupHeader extends StatelessWidget {
),
child: Row(
children: [
GroupAvatars(
chat: chat,
contacts: contacts,
@@ -49,7 +48,6 @@ class GroupHeader extends StatelessWidget {
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -67,7 +65,6 @@ class GroupHeader extends StatelessWidget {
const SizedBox(height: 2),
Row(
children: [
if (onlineCount > 0) ...[
Container(
width: 8,
@@ -89,7 +86,6 @@ class GroupHeader extends StatelessWidget {
const SizedBox(width: 8),
],
Text(
'$totalCount участников',
style: TextStyle(

View File

@@ -49,6 +49,8 @@ class _GroupManagementPanelState extends State<GroupManagementPanel> {
.where((contact) => contact != null)
.cast<Contact>()
.toList();
final totalParticipantsCount =
widget.chat.participantsCount ?? participantIds.length;
return Container(
decoration: BoxDecoration(
@@ -57,7 +59,6 @@ class _GroupManagementPanelState extends State<GroupManagementPanel> {
),
child: Column(
children: [
Container(
margin: const EdgeInsets.only(top: 8),
width: 40,
@@ -68,7 +69,6 @@ class _GroupManagementPanelState extends State<GroupManagementPanel> {
),
),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
@@ -93,7 +93,7 @@ class _GroupManagementPanelState extends State<GroupManagementPanel> {
),
),
Text(
'${participants.length} участников',
'$totalParticipantsCount участников',
style: TextStyle(
fontSize: 14,
color: colors.onSurfaceVariant,
@@ -133,7 +133,6 @@ class _GroupManagementPanelState extends State<GroupManagementPanel> {
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: SizedBox(
@@ -151,7 +150,6 @@ class _GroupManagementPanelState extends State<GroupManagementPanel> {
),
),
Expanded(
child: ListView.builder(
controller: scrollController,
@@ -349,10 +347,8 @@ class _GroupManagementPanelState extends State<GroupManagementPanel> {
int userId, {
required bool cleanMessages,
}) async {
print('Удаляем участника $userId, очистка сообщений: $cleanMessages');
_apiService.sendMessage(widget.chat.id, '', replyToMessageId: null);
}
}

View File

@@ -6,6 +6,7 @@ import FlutterMacOS
import Foundation
import app_links
import audio_session
import device_info_plus
import dynamic_color
import file_picker
@@ -13,6 +14,7 @@ import file_selector_macos
import flutter_inappwebview_macos
import flutter_secure_storage_macos
import flutter_timezone
import just_audio
import mobile_scanner
import open_file_mac
import package_info_plus
@@ -27,6 +29,7 @@ import wakelock_plus
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
@@ -34,6 +37,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin"))
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))

View File

@@ -65,6 +65,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.0"
audio_session:
dependency: transitive
description:
name: audio_session
sha256: "2b7fff16a552486d078bfc09a8cde19f426dc6d6329262b684182597bec5b1ac"
url: "https://pub.dev"
source: hosted
version: "0.1.25"
boolean_selector:
dependency: transitive
description:
@@ -701,6 +709,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.9.0"
just_audio:
dependency: "direct main"
description:
name: just_audio
sha256: f978d5b4ccea08f267dae0232ec5405c1b05d3f3cd63f82097ea46c015d5c09e
url: "https://pub.dev"
source: hosted
version: "0.9.46"
just_audio_platform_interface:
dependency: transitive
description:
name: just_audio_platform_interface
sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a"
url: "https://pub.dev"
source: hosted
version: "4.6.0"
just_audio_web:
dependency: transitive
description:
name: just_audio_web
sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663"
url: "https://pub.dev"
source: hosted
version: "0.4.16"
leak_tracker:
dependency: transitive
description:

View File

@@ -122,6 +122,8 @@ dependencies:
chewie: ^1.7.5
just_audio: ^0.9.40
dev_dependencies:
flutter_test:
sdk: flutter