diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 8d6f6b8..288a097 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'package:flutter/material.dart' show Locale; import '../api.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; @@ -431,6 +432,82 @@ class AccountModule { return config; } + Future updateProfileName(String firstName, String? lastName) async { + _ensureOnline(); + final payload = { + 'firstName': firstName, + }; + if (lastName != null) payload['lastName'] = lastName; + final packet = await _api.sendRequest(Opcode.profile, payload); + if (packet.isError) { + throw Exception(packet.payload?.toString() ?? 'Server error'); + } + final data = packet.payload as Map?; + if (data == null) throw Exception('Empty response'); + final profile = data['profile'] as Map?; + if (profile == null) throw Exception('No profile in response'); + final contact = profile['contact'] as Map?; + if (contact == null) throw Exception('No contact in response'); + final newProfile = ProfileData.fromServerMap(contact.cast()); + await AppDatabase.saveProfile(newProfile, isActive: true); + return newProfile; + } + + Future updateProfileAvatar(String photoToken, String avatarType) async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.profile, { + 'photoToken': photoToken, + 'avatarType': avatarType, + }); + if (packet.isError) { + throw Exception(packet.payload?.toString() ?? 'Server error'); + } + final data = packet.payload as Map?; + if (data == null) throw Exception('Empty response'); + final profile = data['profile'] as Map?; + if (profile == null) throw Exception('No profile in response'); + final contact = profile['contact'] as Map?; + if (contact == null) throw Exception('No contact in response'); + final newProfile = ProfileData.fromServerMap(contact.cast()); + await AppDatabase.saveProfile(newProfile, isActive: true); + return newProfile; + } + + Future getAvatarUploadUrl() async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.photoUpload, { + 'count': 1, + 'profile': true, + }); + if (packet.isError) { + throw Exception(packet.payload?.toString() ?? 'Server error'); + } + final data = packet.payload as Map?; + if (data == null) throw Exception('Empty response'); + final url = data['url'] as String?; + if (url == null) throw Exception('No url in response'); + return url; + } + + Future removeProfilePhoto(int photoId) async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.removeContactPhoto, { + 'photoId': photoId, + }); + if (packet.isError) { + throw Exception(packet.payload?.toString() ?? 'Server error'); + } + final data = packet.payload as Map?; + if (data == null) throw Exception('Empty response'); + final profile = data['profile'] as Map?; + if (profile == null) throw Exception('No profile in response'); + final contact = profile['contact'] as Map?; + if (contact == null) throw Exception('No contact in response'); + final newProfile = ProfileData.fromServerMap(contact.cast()); + await AppDatabase.saveProfile(newProfile, isActive: true); + return newProfile; + } + // 2FA Creation (when not set) Future create2faTrack() async { _ensureOnline(); @@ -931,7 +1008,7 @@ class AccountModule { throw Exception('login: отсутствует profile.contact в ответе'); } final profile = ProfileData.fromServerMap(contact.cast()); - await AppDatabase.saveProfile(profile); + await AppDatabase.saveProfile(profile, isActive: true); await AppDatabase.setActiveAccount(profile.id); await _saveSyncState(data, serverTime, profile.id); diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 255ff2e..150e5ca 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -23,6 +23,34 @@ class ContactCache { static String? getAvatar(int id) => _avatarCache[id]; } +class TranscriptionResult { + final int status; + final String? text; + final String? messageId; + final int? chatId; + final int? mediaId; + + TranscriptionResult({ + required this.status, + this.text, + this.messageId, + this.chatId, + this.mediaId, + }); +} + +class TranscriptionCache { + static final Map _cache = {}; + + static void put(String messageId, TranscriptionResult result) { + _cache[messageId] = result; + } + + static TranscriptionResult? get(String messageId) => _cache[messageId]; + + static bool has(String messageId) => _cache.containsKey(messageId); +} + class CachedMessage { final String id; final int accountId; @@ -247,6 +275,35 @@ class MessagesModule { await _api.sendRequest(Opcode.msgSend, payload); } + Future requestTranscription( + int chatId, + int messageId, + int mediaId, + ) async { + final payload = { + 'chatId': chatId, + 'messageId': messageId, + 'mediaId': mediaId, + }; + + final response = await _api.sendRequest(Opcode.audioTranscription, payload); + if (!response.isOk) return TranscriptionResult(status: -1); + + final data = response.payload; + if (data is! Map) return TranscriptionResult(status: -1); + + final transcriptionStatus = data['transcriptionStatus'] as int? ?? -1; + if (transcriptionStatus == 1) { + final text = data['transcription'] as String? ?? ''; + if (text.isEmpty) { + return TranscriptionResult(status: 1, text: 'не удалось распознать текст'); + } + return TranscriptionResult(status: 1, text: text); + } + + return TranscriptionResult(status: transcriptionStatus); + } + Future downloadPhoto(String baseUrl, String photoToken) async { try { final response = await _api.sendRequest(Opcode.fileDownload, { diff --git a/lib/core/protocol/opcode_map.dart b/lib/core/protocol/opcode_map.dart index edc40e5..8f0f5c9 100644 --- a/lib/core/protocol/opcode_map.dart +++ b/lib/core/protocol/opcode_map.dart @@ -170,6 +170,10 @@ abstract class Opcode { static const int notifBanners = 292; // Баннеры static const int notifFolders = 277; // Обновление папок + // ── Transcription ─────────────────────────────────────────────────── + static const int audioTranscription = 202; // Запрос транскрибации аудио + static const int transcriptionResult = 293; // Результат транскрибации (push) + // ── Misc ─────────────────────────────────────────────────────────── static const int okToken = 158; // OK-токен static const int webAppInitData = 160; // Данные WebApp @@ -332,6 +336,8 @@ abstract class Opcode { notifProfile: 'NOTIF_PROFILE', notifBanners: 'NOTIF_BANNERS', notifFolders: 'NOTIF_FOLDERS', + audioTranscription: 'AUDIO_TRANSCRIPTION', + transcriptionResult: 'TRANSCRIPTION_RESULT', okToken: 'OK_TOKEN', webAppInitData: 'WEB_APP_INIT_DATA', complain: 'COMPLAIN', diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 7283d9b..24f02d9 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -98,7 +98,7 @@ class ProfileData { ); } - Map toDbRow() => { + Map toDbRow({bool isActive = false}) => { 'id': id, 'first_name': firstName, 'last_name': lastName, @@ -109,6 +109,7 @@ class ProfileData { 'country': country, 'account_status': accountStatus, 'update_time': updateTime, + 'is_active': isActive ? 1 : 0, 'profile_options': profileOptions?.join(','), }; } @@ -280,11 +281,11 @@ class AppDatabase { ) '''; - static Future saveProfile(ProfileData profile) async { + static Future saveProfile(ProfileData profile, {bool isActive = true}) async { final db = await _instance; await db.insert( 'profile', - profile.toDbRow(), + profile.toDbRow(isActive: isActive), conflictAlgorithm: ConflictAlgorithm.replace, ); } diff --git a/lib/core/transport/dispatcher.dart b/lib/core/transport/dispatcher.dart index a7ff78e..a97b3c8 100644 --- a/lib/core/transport/dispatcher.dart +++ b/lib/core/transport/dispatcher.dart @@ -53,8 +53,12 @@ class PacketDispatcher { if (packet.cmd == CmdType.ok || packet.cmd == CmdType.error || packet.cmd == CmdType.notFound) { + final payloadStr = packet.payload.toString(); + final displayPayload = packet.opcode == Opcode.login && payloadStr.length > 50 + ? '${payloadStr.substring(0, 50)}...' + : payloadStr; logger.i( - '<= {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${packet.payload}}', + '<= {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: $displayPayload}', ); final completer = _pendingRequests.remove(packet.seq); diff --git a/lib/frontend/screens/calls/calls_tab.dart b/lib/frontend/screens/calls/calls_tab.dart index 7e08f23..164f175 100644 --- a/lib/frontend/screens/calls/calls_tab.dart +++ b/lib/frontend/screens/calls/calls_tab.dart @@ -1,3 +1,4 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart' show api; @@ -166,10 +167,11 @@ class _CallsTabState extends State { ), child: ClipOval( child: call.avatarUrl != null && call.avatarUrl!.isNotEmpty - ? Image.network( - call.avatarUrl!, + ? CachedNetworkImage( + imageUrl: call.avatarUrl!, fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => + fadeInDuration: const Duration(milliseconds: 120), + errorWidget: (context, url, error) => _buildPlaceholderAvatar(cs, call.name), ) : _buildPlaceholderAvatar(cs, call.name), diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index b52a632..a2ffb88 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:komet/backend/modules/messages.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -93,6 +94,7 @@ class _ChatListScreenState extends State final List _folderChatScrollControllers = []; final List _folderChatScrollListenerFns = []; final Set _selectedChats = {}; + final Set _inflightContactIds = {}; DateTime _storiesRevealLayoutSettleUntil = DateTime.fromMillisecondsSinceEpoch(0); @@ -157,14 +159,11 @@ class _ChatListScreenState extends State vsync: this, duration: const Duration(milliseconds: 350), ); - _navPageAnimController = - AnimationController( - vsync: this, - duration: const Duration(milliseconds: 350), - value: 1.0, - )..addListener(() { - if (mounted) setState(() {}); - }); + _navPageAnimController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 350), + value: 1.0, + ); _shimmerController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1500), @@ -262,6 +261,7 @@ class _ChatListScreenState extends State } _isInitialLoading = false; }); + _prefetchContactsForChats(chats); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _jumpFolderPageToSelection(); @@ -315,6 +315,33 @@ class _ChatListScreenState extends State return 0; } + void _prefetchContactsForChats(List chats) { + final myId = _profile?.id; + final ids = {}; + for (final chat in chats) { + if (chat.type == 'DIALOG' && chat.id != 0) { + for (final entry in chat.participants.entries) { + if (entry.key != myId) { + ids.add(entry.key); + break; + } + } + } + final senderId = chat.lastMsgSenderId; + if (senderId != null) ids.add(senderId); + } + ids.removeWhere((id) => ContactCache.get(id) != null); + ids.removeAll(_inflightContactIds); + if (ids.isEmpty) return; + _inflightContactIds.addAll(ids); + for (final id in ids) { + messagesModule.searchContactById(id).whenComplete(() { + _inflightContactIds.remove(id); + if (mounted) setState(() {}); + }); + } + } + List _chatsForPageIndex(int pageIndex) { if (_folders.isEmpty) return _chats; if (pageIndex < 0 || pageIndex >= _folders.length) return _chats; @@ -1028,7 +1055,10 @@ class _ChatListScreenState extends State final chat = chats[index]; if (chat.type.isNotEmpty && chat.type == "DIALOG" && chat.id != 0) { - final secondId = chat.participants.entries.where((entry) => entry.key != _profile?.id).first.key; + final secondId = chat.participants.entries + .where((entry) => entry.key != _profile?.id) + .first + .key; final name = ContactCache.get(secondId); final avatar = ContactCache.getAvatar(secondId); @@ -1354,17 +1384,6 @@ class _ChatListScreenState extends State return lo + 4; } - final pageDisplayT = _effectivePageNavRowT( - inactiveWidth: inactiveWidth, - bubbleLeftForIndex: bubbleLeftForPageT, - ); - - final showChatsFab = - !_isSelectionMode && - (_navDragging || _navPageAnimController.isAnimating - ? pageDisplayT < 1.0 - : _currentNavIndex == 0); - return Stack( children: [ ClipRect( @@ -1378,8 +1397,8 @@ class _ChatListScreenState extends State child: SizedBox( width: pageW * 4, height: pageH, - child: Transform.translate( - offset: Offset(-pageDisplayT * pageW, 0), + child: AnimatedBuilder( + animation: _navPageAnimController, child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -1413,15 +1432,37 @@ class _ChatListScreenState extends State ), ], ), + builder: (context, child) { + final pageDisplayT = _effectivePageNavRowT( + inactiveWidth: inactiveWidth, + bubbleLeftForIndex: bubbleLeftForPageT, + ); + return Transform.translate( + offset: Offset(-pageDisplayT * pageW, 0), + child: child, + ); + }, ), ), ), ), ), _buildDockedBottomNav(cs, navInnerW, bottomInset), - ListenableBuilder( - listenable: _fabController, - builder: (context, child) { + AnimatedBuilder( + animation: Listenable.merge([ + _fabController, + _navPageAnimController, + ]), + builder: (context, _) { + final pageDisplayT = _effectivePageNavRowT( + inactiveWidth: inactiveWidth, + bubbleLeftForIndex: bubbleLeftForPageT, + ); + final showChatsFab = + !_isSelectionMode && + (_navDragging || _navPageAnimController.isAnimating + ? pageDisplayT < 1.0 + : _currentNavIndex == 0); final double val = Curves.easeOutCubic.transform( _fabController.value, ); @@ -1563,7 +1604,7 @@ class _ChatListScreenState extends State ), child: CircleAvatar( radius: 26, - backgroundImage: NetworkImage(imageUrl), + backgroundImage: CachedNetworkImageProvider(imageUrl), ), ), const SizedBox(height: 6), @@ -1717,7 +1758,7 @@ Navigator.push( radius: 24, backgroundColor: cs.surfaceContainerHighest, backgroundImage: imageUrl.isNotEmpty - ? NetworkImage(imageUrl) + ? CachedNetworkImageProvider(imageUrl) : null, child: imageUrl.isEmpty ? Text( @@ -2019,7 +2060,7 @@ Navigator.push( ), child: CircleAvatar( radius: 12, - backgroundImage: NetworkImage(imageUrl), + backgroundImage: CachedNetworkImageProvider(imageUrl), ), ), ); diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 22c8d0c..13d8b3d 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:komet/backend/modules/chats.dart'; import 'package:komet/frontend/screens/chats/chat_info_screen.dart'; @@ -258,18 +259,18 @@ class _ChatScreenState extends State return Scaffold( backgroundColor: cs.surface, appBar: PreferredSize( - preferredSize: Size.fromHeight(kToolbarHeight), + preferredSize: Size.fromHeight(kToolbarHeight), child: InkWell( onTap: () => Navigator.push( - context, + context, MaterialPageRoute(builder: (context) => ChatInfoScreen( - chatId: widget.chatId, + chatId: widget.chatId, name: widget.name, imageUrl: widget.imageUrl, chatType: widget.chatType) ) ), - child: AppBar( + child: AppBar( backgroundColor: cs.surfaceContainerHigh, foregroundColor: cs.onSurface, elevation: 0, @@ -285,7 +286,7 @@ class _ChatScreenState extends State if (widget.imageUrl.isNotEmpty) CircleAvatar( radius: 18, - backgroundImage: NetworkImage(widget.imageUrl), + backgroundImage: CachedNetworkImageProvider(widget.imageUrl), ) else CircleAvatar( diff --git a/lib/frontend/screens/contacts/contacts_tab.dart b/lib/frontend/screens/contacts/contacts_tab.dart index 3d86580..a2d00d6 100644 --- a/lib/frontend/screens/contacts/contacts_tab.dart +++ b/lib/frontend/screens/contacts/contacts_tab.dart @@ -1,3 +1,4 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../core/storage/app_database.dart'; @@ -84,10 +85,11 @@ class _ContactsTabState extends State { ), child: ClipOval( child: contact.baseUrl != null && contact.baseUrl!.isNotEmpty - ? Image.network( - contact.baseUrl!, + ? CachedNetworkImage( + imageUrl: contact.baseUrl!, fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => + fadeInDuration: const Duration(milliseconds: 120), + errorWidget: (context, url, error) => _buildPlaceholderAvatar(cs, nameToDisplay), ) : _buildPlaceholderAvatar(cs, nameToDisplay), diff --git a/lib/frontend/screens/profile/edit_profile_screen.dart b/lib/frontend/screens/profile/edit_profile_screen.dart new file mode 100644 index 0000000..11a94c5 --- /dev/null +++ b/lib/frontend/screens/profile/edit_profile_screen.dart @@ -0,0 +1,252 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../../../core/storage/app_database.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../../main.dart' show accountModule, KometApp; +import '../../widgets/custom_notification.dart'; + +class EditProfileScreen extends StatefulWidget { + const EditProfileScreen({super.key}); + + @override + State createState() => _EditProfileScreenState(); +} + +class _EditProfileScreenState extends State { + final _firstNameController = TextEditingController(); + final _lastNameController = TextEditingController(); + bool _isLoading = true; + bool _isSaving = false; + String? _avatarUrl; + int? _photoId; + + @override + void initState() { + super.initState(); + _loadProfile(); + } + + @override + void dispose() { + _firstNameController.dispose(); + _lastNameController.dispose(); + super.dispose(); + } + + Future _loadProfile() async { + final profile = await AppDatabase.loadActiveProfile(); + if (!mounted) return; + if (profile != null) { + _firstNameController.text = profile.firstName; + _lastNameController.text = profile.lastName ?? ''; + _avatarUrl = profile.baseUrl; + _photoId = profile.photoId; + setState(() => _isLoading = false); + } else { + setState(() => _isLoading = false); + } + } + + Future _saveName() async { + if (_isSaving) return; + final firstName = _firstNameController.text.trim(); + if (firstName.isEmpty) { + if (mounted) showCustomNotification(context, 'Имя не может быть пустым'); + return; + } + setState(() => _isSaving = true); + try { + final newProfile = await accountModule.updateProfileName( + firstName, + _lastNameController.text.trim().isEmpty ? null : _lastNameController.text.trim(), + ); + _avatarUrl = newProfile.baseUrl; + _photoId = newProfile.photoId; + KometApp.stateOf(context)?.notifyProfileUpdate(); + if (mounted) { + showCustomNotification(context, 'Имя сохранено'); + setState(() => _isSaving = false); + } + } catch (e) { + if (!mounted) return; + showCustomNotification(context, 'Ошибка: $e'); + setState(() => _isSaving = false); + } + } + + Future _changeAvatar() async { + if (_isSaving) return; + try { + final uploadUrl = await accountModule.getAvatarUploadUrl(); + if (!mounted) return; + showCustomNotification(context, 'Загрузка аватарки: $uploadUrl (пока нет)'); + } catch (e) { + if (mounted) showCustomNotification(context, 'Ошибка: $e'); + } + } + + Future _removeAvatar() async { + if (_isSaving || _photoId == null) return; + setState(() => _isSaving = true); + try { + final newProfile = await accountModule.removeProfilePhoto(_photoId!); + _avatarUrl = newProfile.baseUrl; + _photoId = newProfile.photoId; + KometApp.stateOf(context)?.notifyProfileUpdate(); + if (mounted) { + showCustomNotification(context, 'Фото удалено'); + setState(() => _isSaving = false); + } + } catch (e) { + if (!mounted) return; + showCustomNotification(context, 'Ошибка: $e'); + setState(() => _isSaving = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context); + + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + title: Text( + l10n?.editProfileTitle ?? 'Edit Profile', + style: TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600), + ), + actions: [ + TextButton( + onPressed: _isLoading || _isSaving ? null : _saveName, + child: _isSaving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + l10n?.editProfileSave ?? 'Save', + style: TextStyle(color: cs.primary, fontWeight: FontWeight.w600), + ), + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + Center( + child: Stack( + children: [ + Container( + width: 88, + height: 88, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: cs.primary.withValues(alpha: 0.5), + width: 2.5, + ), + ), + child: ClipOval( + child: _avatarUrl != null && _avatarUrl!.isNotEmpty + ? Image.network(_avatarUrl!, fit: BoxFit.cover) + : Container( + color: cs.primaryContainer, + alignment: Alignment.center, + child: Text( + _firstNameController.text.isNotEmpty + ? _firstNameController.text[0].toUpperCase() + : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 32, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + Positioned( + bottom: 0, + right: 0, + child: Container( + decoration: BoxDecoration( + color: cs.primary, + shape: BoxShape.circle, + ), + child: IconButton( + icon: Icon(Symbols.camera_alt, color: cs.onPrimary, size: 20), + onPressed: _changeAvatar, + ), + ), + ), + ], + ), + ), + if (_photoId != null) ...[ + const SizedBox(height: 8), + Center( + child: TextButton( + onPressed: _removeAvatar, + child: Text( + l10n?.editProfileRemovePhoto ?? 'Remove photo', + style: TextStyle(color: cs.error), + ), + ), + ), + ], + const SizedBox(height: 24), + _buildTextField( + l10n?.editProfileFirstName ?? 'First name', + _firstNameController, + cs, + enabled: !_isSaving, + ), + const SizedBox(height: 12), + _buildTextField( + l10n?.editProfileLastName ?? 'Last name', + _lastNameController, + cs, + enabled: !_isSaving, + ), + const SizedBox(height: 120), + ], + ), + ); + } + + Widget _buildTextField(String label, TextEditingController controller, ColorScheme cs, {bool enabled = true}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 6), + child: Text(label, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + ), + TextField( + controller: controller, + enabled: enabled, + decoration: InputDecoration( + filled: true, + fillColor: cs.surfaceContainerHigh, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/frontend/screens/profile/info_screen.dart b/lib/frontend/screens/profile/info_screen.dart index 715ccd1..58febca 100644 --- a/lib/frontend/screens/profile/info_screen.dart +++ b/lib/frontend/screens/profile/info_screen.dart @@ -115,11 +115,11 @@ class _InfoScreenState extends State { padding: const EdgeInsets.all(16), children: [ _buildSectionTitle(l10n.infoAccountSection, cs), - ...accountKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(info[e.key]), cs)), + ...accountKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(info[e.key], e.key), cs)), const SizedBox(height: 16), _buildSectionTitle(l10n.infoServerSection, cs), - ...serverKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(server?[e.key]), cs)), + ...serverKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(server?[e.key], e.key), cs)), const SizedBox(height: 8), _buildSectionTitle(l10n.infoYMapSection, cs), @@ -240,28 +240,17 @@ class _InfoScreenState extends State { ); } - String _formatValue(dynamic value) { + String _formatValue(dynamic value, String key) { if (value == null) return '-'; if (value is Map && value.containsKey('chatMarker')) { final ts = value['chatMarker'] as int?; - if (ts != null) { - final dt = DateTime.fromMillisecondsSinceEpoch(ts); - return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' - '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}'; - } - return '-'; + return ts != null ? _formatTs(ts) : '-'; } - if (value is int && value > 1000000000000) { - final dt = DateTime.fromMillisecondsSinceEpoch(value); - return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' - '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}'; - } - if (value is int && value > 86400) { + if (value is int && value > 1000000000000) return _formatTs(value); + if (key == 'edit-timeout' && value is int && value > 0) { final weeks = value ~/ 604800; final days = (value % 604800) ~/ 86400; - if (weeks > 0) { - return '$weeks ${_w(weeks)} ${days > 0 ? '$days ${_d(days)}' : ''}'.trim(); - } + if (weeks > 0) return '$weeks ${_w(weeks)} ${days > 0 ? '$days ${_d(days)}' : ''}'.trim(); final h = value ~/ 3600; final m = (value % 3600) ~/ 60; if (h > 0) return '${h}h ${m}m'; @@ -270,6 +259,13 @@ class _InfoScreenState extends State { return value.toString(); } + String _formatTs(int ts) { + if (ts < 1000000000000) return ts.toString(); + final dt = DateTime.fromMillisecondsSinceEpoch(ts); + return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' + '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}'; + } + String _w(int n) { final m = n % 10; if (m == 1 && n != 11) return 'нед'; diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 3804ac8..f38811b 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -1,13 +1,16 @@ import 'dart:async'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../../../core/storage/app_database.dart'; import '../../../l10n/app_localizations.dart'; +import '../../../main.dart'; import '../auth/proxy_settings_sheet.dart'; import 'debug_menu_screen.dart'; import 'devices_screen.dart'; +import 'edit_profile_screen.dart'; import 'info_screen.dart'; import 'security_screen.dart'; import 'spoof_screen.dart'; @@ -26,17 +29,25 @@ class _SettingsTabState extends State { bool _debugMenuVisible = false; int _versionSecretTapCount = 0; Timer? _versionSecretTapResetTimer; + StreamSubscription? _profileUpdateSub; @override void initState() { super.initState(); _loadProfile(); _loadAppVersion(); + final appState = KometApp.stateOf(context); + if (appState != null) { + _profileUpdateSub = appState.profileUpdateStream.listen((_) { + if (mounted) _loadProfile(); + }); + } } @override void dispose() { _versionSecretTapResetTimer?.cancel(); + _profileUpdateSub?.cancel(); super.dispose(); } @@ -316,7 +327,14 @@ child: _buildSection( size: 22, weight: 400, ), - onPressed: () {}, + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const EditProfileScreen(), + ), + ); + }, ), ], ), @@ -333,10 +351,11 @@ child: _buildSection( ), child: ClipOval( child: _profile?.baseUrl != null && _profile!.baseUrl!.isNotEmpty - ? Image.network( - _profile!.baseUrl!, + ? CachedNetworkImage( + imageUrl: _profile!.baseUrl!, fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => + fadeInDuration: const Duration(milliseconds: 120), + errorWidget: (context, url, error) => _buildPlaceholderAvatar(cs, name), ) : _buildPlaceholderAvatar(cs, name), @@ -514,7 +533,21 @@ class _PhoneSpoilerState extends State<_PhoneSpoiler> _controller = AnimationController( vsync: this, duration: const Duration(seconds: 2), - )..repeat(); + ); + if (!widget.isVisible) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant _PhoneSpoiler oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isVisible == oldWidget.isVisible) return; + if (widget.isVisible) { + _controller.stop(); + } else if (!_controller.isAnimating) { + _controller.repeat(); + } } @override diff --git a/lib/frontend/widgets/custom_notification.dart b/lib/frontend/widgets/custom_notification.dart index 5bdb22b..f30fcec 100644 --- a/lib/frontend/widgets/custom_notification.dart +++ b/lib/frontend/widgets/custom_notification.dart @@ -10,7 +10,7 @@ void showCustomNotificationOnOverlay(OverlayState overlay, String message) { builder: (context) => CustomNotification(message: message), ); overlay.insert(entry); - Future.delayed(const Duration(milliseconds: 1900), () { + Future.delayed(const Duration(milliseconds: 2600), () { entry.remove(); }); } @@ -38,7 +38,7 @@ class _CustomNotificationState extends State ); _opacity = Tween(begin: 0.0, end: 1.0).animate(_controller); _controller.forward(); - Future.delayed(const Duration(milliseconds: 1600), () { + Future.delayed(const Duration(milliseconds: 2300), () { if (mounted) _controller.reverse(); }); } diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index db9b7cf..69ec7f2 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -1,3 +1,4 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:komet/backend/modules/chats.dart'; import 'package:komet/backend/modules/contacts.dart'; @@ -111,19 +112,22 @@ class MessageBubble extends StatelessWidget { return MessageType.text; } - // скругление уже смешариков т.е сообщений, те которые isme ? .. Это наши, после : это чужие BorderRadius get _borderRadius { final topRadius = Radius.circular(bubbleBorderRadius); - final bottomRadius = Radius.circular(bubbleBorderRadius); final smallRadius = const Radius.circular(4); + final cornerTL = isMe ? smallRadius : topRadius; + final cornerTR = isMe ? topRadius : smallRadius; + final cornerBL = isMe ? smallRadius : topRadius; + final cornerBR = isMe ? topRadius : smallRadius; + if (_hasPhotoWithCaption && (shape == BubbleShape.singleTop || shape == BubbleShape.singleMiddle || shape == BubbleShape.singleBottom)) { return BorderRadius.only( topLeft: topRadius, - topRight: isMe ? topRadius : topRadius, + topRight: topRadius, bottomLeft: smallRadius, bottomRight: smallRadius, ); @@ -135,16 +139,16 @@ class MessageBubble extends StatelessWidget { return BorderRadius.only( topLeft: smallRadius, topRight: smallRadius, - bottomLeft: isMe ? smallRadius : smallRadius, - bottomRight: isMe ? smallRadius : bottomRadius, + bottomLeft: smallRadius, + bottomRight: isMe ? smallRadius : topRadius, ); } switch (shape) { case BubbleShape.singleTop: return BorderRadius.only( - topLeft: isMe ? topRadius : smallRadius, - topRight: isMe ? smallRadius : topRadius, + topLeft: cornerTL, + topRight: cornerTR, bottomLeft: smallRadius, bottomRight: smallRadius, ); @@ -152,22 +156,22 @@ class MessageBubble extends StatelessWidget { return BorderRadius.only( topLeft: smallRadius, topRight: smallRadius, - bottomLeft: isMe ? topRadius : smallRadius, - bottomRight: isMe ? smallRadius : topRadius, + bottomLeft: cornerBL, + bottomRight: cornerBR, ); case BubbleShape.singleMiddle: return BorderRadius.only( - topLeft: topRadius, - topRight: topRadius, - bottomLeft: isMe ? topRadius : smallRadius, - bottomRight: isMe ? smallRadius : topRadius, + topLeft: cornerTL, + topRight: cornerTR, + bottomLeft: cornerBL, + bottomRight: cornerBR, ); case BubbleShape.groupedMiddle: return BorderRadius.only( - topLeft: isMe ? topRadius : smallRadius, - topRight: isMe ? smallRadius : smallRadius, - bottomLeft: isMe ? topRadius : smallRadius, - bottomRight: isMe ? smallRadius : smallRadius, + topLeft: cornerTL, + topRight: smallRadius, + bottomLeft: cornerBL, + bottomRight: smallRadius, ); } } @@ -272,13 +276,13 @@ class MessageBubble extends StatelessWidget { case MessageType.voice: switch (shape) { case BubbleShape.groupedMiddle: - return const EdgeInsets.symmetric(horizontal: 14, vertical: 6); + return const EdgeInsets.symmetric(horizontal: 14, vertical: 4); case BubbleShape.singleTop: - return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); + return const EdgeInsets.symmetric(horizontal: 14, vertical: 6); case BubbleShape.singleBottom: - return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); + return const EdgeInsets.symmetric(horizontal: 14, vertical: 6); case BubbleShape.singleMiddle: - return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); + return const EdgeInsets.symmetric(horizontal: 14, vertical: 4); } } return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); @@ -314,7 +318,7 @@ class MessageBubble extends StatelessWidget { ? [ CircleAvatar( radius: 15, - backgroundImage: NetworkImage(senderAvatar), + backgroundImage: CachedNetworkImageProvider(senderAvatar), backgroundColor: cs.primaryContainer, ) ] @@ -455,7 +459,7 @@ class MessageBubble extends StatelessWidget { if (senderAvatar != null && senderAvatar.isNotEmpty) CircleAvatar( radius: 10, - backgroundImage: NetworkImage(senderAvatar), + backgroundImage: CachedNetworkImageProvider(senderAvatar), backgroundColor: cs.primaryContainer, ) else @@ -707,7 +711,7 @@ class MessageBubble extends StatelessWidget { if (senderAvatar != null && senderAvatar.isNotEmpty) CircleAvatar( radius: 10, - backgroundImage: NetworkImage(senderAvatar), + backgroundImage: CachedNetworkImageProvider(senderAvatar), backgroundColor: cs.primaryContainer, ) else @@ -780,7 +784,7 @@ class MessageBubble extends StatelessWidget { if (senderAvatar != null && senderAvatar.isNotEmpty) CircleAvatar( radius: 10, - backgroundImage: NetworkImage(senderAvatar), + backgroundImage: CachedNetworkImageProvider(senderAvatar), backgroundColor: cs.primaryContainer, ) else @@ -846,7 +850,7 @@ class MessageBubble extends StatelessWidget { if (senderAvatar != null && senderAvatar.isNotEmpty) CircleAvatar( radius: 10, - backgroundImage: NetworkImage(senderAvatar), + backgroundImage: CachedNetworkImageProvider(senderAvatar), backgroundColor: cs.primaryContainer, ) else @@ -912,12 +916,13 @@ class MessageBubble extends StatelessWidget { child: Stack( children: [ if (imageUrl.isNotEmpty) - Image.network( - imageUrl, + CachedNetworkImage( + imageUrl: imageUrl, width: constrainedWidth, height: constrainedHeight, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => _buildPhotoPlaceholder( + fadeInDuration: const Duration(milliseconds: 120), + errorWidget: (_, __, ___) => _buildPhotoPlaceholder( ctx, constrainedWidth, constrainedHeight, @@ -1016,12 +1021,13 @@ class MessageBubble extends StatelessWidget { child: Stack( children: [ if (imageUrl.isNotEmpty) - Image.network( - imageUrl, + CachedNetworkImage( + imageUrl: imageUrl, fit: BoxFit.cover, width: double.infinity, height: double.infinity, - errorBuilder: (_, __, ___) => + fadeInDuration: const Duration(milliseconds: 120), + errorWidget: (_, __, ___) => _buildPhotoPlaceholder(ctx, 100, 100), ) else @@ -1050,12 +1056,13 @@ class MessageBubble extends StatelessWidget { child: Stack( children: [ if (imageUrl.isNotEmpty) - Image.network( - imageUrl, + CachedNetworkImage( + imageUrl: imageUrl, fit: BoxFit.cover, width: double.infinity, height: double.infinity, - errorBuilder: (_, __, ___) => + fadeInDuration: const Duration(milliseconds: 120), + errorWidget: (_, __, ___) => _buildPhotoPlaceholder(ctx, 100, 100), ) else @@ -1279,12 +1286,13 @@ class MessageBubble extends StatelessWidget { child: Stack( children: [ if (imageUrl.isNotEmpty) - Image.network( - imageUrl, + CachedNetworkImage( + imageUrl: imageUrl, width: 150, height: 150, fit: BoxFit.contain, - errorBuilder: (_, __, ___) => + fadeInDuration: const Duration(milliseconds: 120), + errorWidget: (_, __, ___) => _buildPhotoPlaceholder(ctx, 150, 150), ) else @@ -1336,10 +1344,11 @@ class MessageBubble extends StatelessWidget { child: photoUrl != null && photoUrl.isNotEmpty ? ClipRRect( borderRadius: BorderRadius.circular(24), - child: Image.network( - photoUrl, + child: CachedNetworkImage( + imageUrl: photoUrl, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Icon( + fadeInDuration: const Duration(milliseconds: 120), + errorWidget: (_, __, ___) => Icon( Symbols.person, color: isMe ? Colors.white : cs.primary, size: 24, @@ -1438,7 +1447,7 @@ class MessageBubble extends StatelessWidget { if (senderAvatar != null && senderAvatar.isNotEmpty) CircleAvatar( radius: 10, - backgroundImage: NetworkImage(senderAvatar), + backgroundImage: CachedNetworkImageProvider(senderAvatar), backgroundColor: cs.primaryContainer, ) else @@ -1481,10 +1490,11 @@ class MessageBubble extends StatelessWidget { child: photoUrl != null && photoUrl.isNotEmpty ? ClipRRect( borderRadius: BorderRadius.circular(24), - child: Image.network( - photoUrl, + child: CachedNetworkImage( + imageUrl: photoUrl, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Icon( + fadeInDuration: const Duration(milliseconds: 120), + errorWidget: (_, __, ___) => Icon( Symbols.person, color: isMe ? Colors.white : cs.primary, size: 24, @@ -1545,23 +1555,47 @@ class MessageBubble extends StatelessWidget { final textColor = isMe ? Colors.white : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); - final payload = message.payload; - final voice = payload?['voice'] as Map?; - final duration = voice?['duration'] as int? ?? 0; - final url = voice?['url']?.toString() ?? ''; - return Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - _VoiceMessageBubble( - duration: duration, - url: url, - textColor: textColor, - isMe: isMe, - ), - const SizedBox(height: 6), - _buildMeta(context), - ], + int duration = 0; + String url = ''; + String? waveData; + int? audioId; + + final attaches = message.attachments; + if (attaches != null && attaches.isNotEmpty) { + for (final a in attaches) { + if (a is AudioAttachment) { + duration = ((a.duration ?? 0) / 1000).round(); + url = a.fileUrl ?? a.baseUrl ?? ''; + waveData = a.waveform; + audioId = a.audioId; + break; + } + } + } + + if (duration == 0 && url.isEmpty) { + final payload = message.payload; + final voice = payload?['voice'] as Map?; + duration = ((voice?['duration'] as int? ?? 0) / 1000).round(); + url = voice?['url']?.toString() ?? ''; + } + + final cachedTranscription = TranscriptionCache.get(message.id); + + return _VoiceMessageBubble( + duration: duration, + url: url, + textColor: textColor, + isMe: isMe, + status: message.status, + time: message.time, + cs: cs, + waveData: waveData, + chatId: message.chatId, + messageId: message.id, + audioId: audioId, + preloadedText: cachedTranscription?.text, ); } @@ -1661,12 +1695,28 @@ class _VoiceMessageBubble extends StatefulWidget { final String url; final Color textColor; final bool isMe; + final String? status; + final int time; + final ColorScheme cs; + final String? waveData; + final int chatId; + final String messageId; + final int? audioId; + final String? preloadedText; const _VoiceMessageBubble({ required this.duration, required this.url, required this.textColor, required this.isMe, + this.status, + required this.time, + required this.cs, + this.waveData, + required this.chatId, + required this.messageId, + this.audioId, + this.preloadedText, }); @override @@ -1676,97 +1726,305 @@ class _VoiceMessageBubble extends StatefulWidget { class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { bool _isPlaying = false; double _progress = 0.0; + bool _transcriptionVisible = false; + String? _transcriptionText; + bool _transcriptionLoading = false; @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - final isDark = cs.brightness == Brightness.dark; + void initState() { + super.initState(); + if (widget.preloadedText != null) { + _transcriptionText = widget.preloadedText; + _transcriptionVisible = true; + } + } - return Container( - width: 220, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( + String _formatDuration(int seconds) { + final min = seconds ~/ 60; + final sec = seconds % 60; + return '$min:${sec.toString().padLeft(2, '0')}'; + } + + String _formatTime(int timestamp) { + final dt = DateTime.fromMillisecondsSinceEpoch(timestamp); + final hour = dt.hour.toString().padLeft(2, '0'); + final minute = dt.minute.toString().padLeft(2, '0'); + return '$hour:$minute'; + } + + Widget _buildStatusIcon() { + final status = widget.status; + IconData icon; + Color color; + + if (status == null || status == 'sending' || status == 'pending') { + icon = Symbols.check; + color = Colors.white54; + } else { + switch (status) { + case 'sent': + icon = Symbols.check; + color = Colors.white54; + case 'delivered': + icon = Symbols.done_all; + color = Colors.white54; + case 'read': + icon = Symbols.done_all; + color = const Color(0xFF34C759); + case 'error': + icon = Symbols.error; + color = Colors.redAccent; + default: + icon = Symbols.check; + color = Colors.white54; + } + } + + return Icon(icon, size: 14, color: color); + } + + Widget build(BuildContext context) { + final isDark = widget.cs.brightness == Brightness.dark; + final waveInactiveColor = widget.isMe + ? Colors.white.withValues(alpha: 0.35) + : (isDark + ? widget.cs.surfaceContainerHighest + : const Color(0xFFD1D1D6)); + final waveActiveColor = widget.isMe + ? Colors.white.withValues(alpha: 0.7) + : widget.cs.primary; + + return SizedBox( + width: 240, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - GestureDetector( - onTap: _togglePlay, - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: widget.isMe - ? Colors.white.withValues(alpha: 0.2) - : cs.primaryContainer, - shape: BoxShape.circle, + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + GestureDetector( + onTap: _togglePlay, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: widget.isMe + ? Colors.white.withValues(alpha: 0.2) + : widget.cs.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + _isPlaying ? Symbols.pause : Symbols.play_arrow, + color: widget.isMe ? Colors.white : widget.cs.primary, + size: 18, + ), + ), ), - child: Icon( - _isPlaying ? Symbols.pause : Symbols.play_arrow, - color: widget.isMe ? Colors.white : cs.primary, - size: 20, - ), - ), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Stack( - children: [ - Container( - height: 24, - decoration: BoxDecoration( - color: widget.isMe - ? Colors.white.withValues(alpha: 0.2) - : (isDark - ? cs.surfaceContainerHighest - : const Color(0xFFD1D1D6)), - borderRadius: BorderRadius.circular(2), - ), - ), - FractionallySizedBox( - widthFactor: _progress.clamp(0.0, 1.0), + const SizedBox(width: 10), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + return GestureDetector( + onTapDown: (details) { + setState(() { + _progress = (details.localPosition.dx / + constraints.maxWidth) + .clamp(0.0, 1.0); + }); + }, + onHorizontalDragUpdate: (details) { + setState(() { + _progress = (details.localPosition.dx / + constraints.maxWidth) + .clamp(0.0, 1.0); + }); + }, child: Container( - height: 24, + height: 4, decoration: BoxDecoration( - color: widget.isMe - ? Colors.white.withValues(alpha: 0.5) - : cs.primary, + color: waveInactiveColor, borderRadius: BorderRadius.circular(2), ), - ), - ), - SizedBox( - height: 24, - child: Center( - child: Text( - _formatDuration(widget.duration), - style: TextStyle( - color: widget.textColor.withValues(alpha: 0.8), - fontSize: 12, - fontWeight: FontWeight.w500, + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: _progress.clamp(0.0, 1.0), + child: Container( + decoration: BoxDecoration( + color: waveActiveColor, + borderRadius: BorderRadius.circular(2), + ), ), ), ), - ), - ], + ); + }, ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: _requestTranscription, + child: SizedBox( + width: 20, + height: 32, + child: Center( + child: _transcriptionLoading + ? SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: widget.textColor.withValues(alpha: 0.6), + ), + ) + : Text( + 'Т', + style: TextStyle( + color: widget.textColor.withValues(alpha: 0.6), + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 2), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _formatDuration(widget.duration), + style: TextStyle( + color: widget.textColor.withValues(alpha: 0.7), + fontSize: 11, + ), + ), + const SizedBox(width: 8), + Expanded( + child: AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + alignment: Alignment.topLeft, + child: _transcriptionVisible + ? Text( + _transcriptionText ?? '', + style: TextStyle( + color: widget.textColor.withValues(alpha: 0.8), + fontSize: 12, + height: 1.3, + ), + maxLines: 10, + overflow: TextOverflow.ellipsis, + ) + : const SizedBox.shrink(), + ), + ), + if (!_transcriptionVisible) ...[ + Text( + _formatTime(widget.time), + style: TextStyle( + color: widget.textColor.withValues(alpha: 0.6), + fontSize: 10, + ), + ), + if (widget.isMe) ...[ + const SizedBox(width: 2), + _buildStatusIcon(), + ], + ], + ], + ), + if (_transcriptionVisible) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + _formatTime(widget.time), + style: TextStyle( + color: widget.textColor.withValues(alpha: 0.6), + fontSize: 10, + ), + ), + if (widget.isMe) ...[ + const SizedBox(width: 2), + _buildStatusIcon(), + ], ], ), - ), + ], ], ), ); } + Widget _buildProgressBar(Color inactive, Color active) { + return Container( + height: 4, + decoration: BoxDecoration( + color: inactive, + borderRadius: BorderRadius.circular(2), + ), + ); + } + void _togglePlay() { setState(() { _isPlaying = !_isPlaying; }); } - String _formatDuration(int seconds) { - final min = seconds ~/ 60; - final sec = seconds % 60; - return '$min:${sec.toString().padLeft(2, '0')}'; + Future _requestTranscription() async { + if (widget.audioId == null) return; + + if (_transcriptionVisible && _transcriptionText != null) { + setState(() { + _transcriptionVisible = false; + }); + return; + } + + if (TranscriptionCache.has(widget.messageId)) { + final cached = TranscriptionCache.get(widget.messageId)!; + setState(() { + _transcriptionText = cached.text ?? 'не удалось распознать текст'; + _transcriptionVisible = true; + }); + return; + } + + setState(() { + _transcriptionLoading = true; + }); + + try { + final result = await messagesModule.requestTranscription( + widget.chatId, + int.tryParse(widget.messageId) ?? 0, + widget.audioId!, + ); + + TranscriptionCache.put(widget.messageId, result); + + setState(() { + _transcriptionLoading = false; + if (result.status == 1) { + _transcriptionText = (result.text == null || result.text!.isEmpty) + ? 'не удалось распознать текст' + : result.text; + _transcriptionVisible = true; + } else if (result.status == 0) { + _transcriptionText = 'транскрибация...'; + _transcriptionVisible = true; + } + }); + } catch (e) { + setState(() { + _transcriptionLoading = false; + _transcriptionText = 'ошибка транскрибации'; + _transcriptionVisible = true; + }); + } } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 174459b..c7b227b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -159,5 +159,10 @@ "chatInfoJoined": "joined:", "chatInfoGroupCreated": "group created:", "chatInfoGroupOwner": "group owner:", - "chatInfoDialogStarted": "dialog started:" + "chatInfoDialogStarted": "dialog started:", + "editProfileTitle": "Edit Profile", + "editProfileSave": "Save", + "editProfileFirstName": "First name", + "editProfileLastName": "Last name", + "editProfileRemovePhoto": "Remove photo" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 15fbdfb..ce14093 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -955,6 +955,36 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'dialog started:'** String get chatInfoDialogStarted; + + /// No description provided for @editProfileTitle. + /// + /// In en, this message translates to: + /// **'Edit Profile'** + String get editProfileTitle; + + /// No description provided for @editProfileSave. + /// + /// In en, this message translates to: + /// **'Save'** + String get editProfileSave; + + /// No description provided for @editProfileFirstName. + /// + /// In en, this message translates to: + /// **'First name'** + String get editProfileFirstName; + + /// No description provided for @editProfileLastName. + /// + /// In en, this message translates to: + /// **'Last name'** + String get editProfileLastName; + + /// No description provided for @editProfileRemovePhoto. + /// + /// In en, this message translates to: + /// **'Remove photo'** + String get editProfileRemovePhoto; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 0320c05..6f4c3c6 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -451,4 +451,19 @@ class AppLocalizationsEn extends AppLocalizations { @override String get chatInfoDialogStarted => 'dialog started:'; + + @override + String get editProfileTitle => 'Edit Profile'; + + @override + String get editProfileSave => 'Save'; + + @override + String get editProfileFirstName => 'First name'; + + @override + String get editProfileLastName => 'Last name'; + + @override + String get editProfileRemovePhoto => 'Remove photo'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index d7780cf..999ea90 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -453,4 +453,19 @@ class AppLocalizationsRu extends AppLocalizations { @override String get chatInfoDialogStarted => 'ЛС начат в:'; + + @override + String get editProfileTitle => 'Редактирование профиля'; + + @override + String get editProfileSave => 'Сохранить'; + + @override + String get editProfileFirstName => 'Имя'; + + @override + String get editProfileLastName => 'Фамилия'; + + @override + String get editProfileRemovePhoto => 'Удалить фото'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index c103607..3d134a9 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -159,5 +159,10 @@ "chatInfoJoined": "Зашли в:", "chatInfoGroupCreated": "Группа создана в:", "chatInfoGroupOwner": "Создатель группы:", - "chatInfoDialogStarted": "ЛС начат в:" + "chatInfoDialogStarted": "ЛС начат в:", + "editProfileTitle": "Редактирование профиля", + "editProfileSave": "Сохранить", + "editProfileFirstName": "Имя", + "editProfileLastName": "Фамилия", + "editProfileRemovePhoto": "Удалить фото" } diff --git a/lib/main.dart b/lib/main.dart index 1654efd..37f1354 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -82,6 +82,8 @@ class KometAppState extends State { late final ValueNotifier fpsOverlayEnabled = ValueNotifier( widget.initialFpsOverlay, ); + final _profileUpdateController = StreamController.broadcast(); + Stream get profileUpdateStream => _profileUpdateController.stream; @override void initState() { @@ -128,6 +130,7 @@ class KometAppState extends State { @override void dispose() { _sessionExpiredSub?.cancel(); + _profileUpdateController.close(); fpsOverlayEnabled.dispose(); super.dispose(); } @@ -152,6 +155,10 @@ class KometAppState extends State { } } + void notifyProfileUpdate() { + _profileUpdateController.add(null); + } + ColorScheme _adjustDarkScheme(ColorScheme base) { return base.copyWith( surface: Color.alphaBlend( diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index 4d72a09..795db87 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -198,14 +198,27 @@ class AudioAttachment extends MessageAttachment { } catch (_) {} } + String? waveStr; + final waveRaw = map['wave']; + if (waveRaw is String) { + waveStr = waveRaw; + } else if (waveRaw is List) { + try { + final bytes = List.from(waveRaw); + final base64 = String.fromCharCodes(bytes); + waveStr = 'data:image/webp;base64,$base64'; + } catch (_) {} + } + return AudioAttachment( previewData: previewStr, - baseUrl: map['baseUrl'] as String?, + baseUrl: map['baseUrl']?.toString(), + fileUrl: map['url']?.toString(), audioId: map['audioId'] as int?, - audioToken: map['audioToken'] as String?, + audioToken: map['token']?.toString(), duration: map['duration'] as int?, size: map['size'] as int?, - waveform: map['waveform'] as String?, + waveform: waveStr, ); } @@ -306,7 +319,7 @@ class StickerAttachment extends MessageAttachment { previewData: previewStr, baseUrl: (map['url'] ?? map['baseUrl'])?.toString(), stickerId: map['stickerId']?.toString(), - stickerPackId: (map['stickerPackId'] ?? map['setId'])?.toString(), + stickerPackId: map['setId']?.toString() ?? map['stickerPackId']?.toString(), width: map['width'] as int?, height: map['height'] as int?, ); diff --git a/pubspec.lock b/pubspec.lock index e834ec0..1644efb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -25,6 +25,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" characters: dependency: transitive description: @@ -145,11 +169,27 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" flutter_lints: dependency: "direct dev" description: @@ -389,6 +429,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.3.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" package_info_plus: dependency: "direct main" description: @@ -485,6 +533,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" shared_preferences: dependency: "direct main" description: @@ -674,6 +730,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 017572f..2e32aa1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -54,6 +54,7 @@ dependencies: shared_preferences: ^2.5.4 package_info_plus: ^9.0.1 mobile_scanner: ^7.2.0 + cached_network_image: ^3.4.1 dev_dependencies: flutter_test: