diff --git a/lib/backend/modules/contacts.dart b/lib/backend/modules/contacts.dart index 153e95e..c95ecc9 100644 --- a/lib/backend/modules/contacts.dart +++ b/lib/backend/modules/contacts.dart @@ -465,6 +465,10 @@ class ContactsModule { } } + static final Map _photosHead = {}; + + static ContactPhotos? cachedPhotos(int contactId) => _photosHead[contactId]; + static Future fetchPhotos( Api api, int contactId, { @@ -482,7 +486,9 @@ class ContactsModule { ? rawUrls.whereType().toList() : []; final total = map['total'] is int ? map['total'] as int : urls.length; - return ContactPhotos(urls: urls, total: total); + final photos = ContactPhotos(urls: urls, total: total); + if (from == 0) _photosHead[contactId] = photos; + return photos; } static Future> getContacts(int accountId) async { diff --git a/lib/backend/modules/media_send.dart b/lib/backend/modules/media_send.dart new file mode 100644 index 0000000..56e5d76 --- /dev/null +++ b/lib/backend/modules/media_send.dart @@ -0,0 +1,338 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +import '../../core/cache/message_session_cache.dart'; +import '../../core/media/gallery_source.dart'; +import '../../core/media/image_optimizer.dart'; +import '../../core/storage/app_database.dart'; +import '../../core/utils/logger.dart'; +import '../../main.dart' show fileUploader, messagesModule; +import 'messages.dart'; + +sealed class MediaSendEvent { + final int chatId; + final String tempId; + final bool scheduled; + + const MediaSendEvent({ + required this.chatId, + required this.tempId, + required this.scheduled, + }); +} + +class MediaSendDone extends MediaSendEvent { + final CachedMessage? message; + final int? scheduledTime; + + const MediaSendDone({ + required super.chatId, + required super.tempId, + required super.scheduled, + this.message, + this.scheduledTime, + }); +} + +class MediaSendFailed extends MediaSendEvent { + const MediaSendFailed({ + required super.chatId, + required super.tempId, + required super.scheduled, + }); +} + +class MediaSendService { + MediaSendService._(); + + static final MediaSendService instance = MediaSendService._(); + + final StreamController _events = + StreamController.broadcast(); + + static const int _historyLimit = 60; + static const int _photoConcurrency = 3; + static const int _photoAttempts = 3; + + final Map>> _progress = {}; + final Map> _pending = {}; + final Map _completed = {}; + final Set _failed = {}; + + Stream get events => _events.stream; + + ValueListenable>? progressFor(String tempId) => + _progress[tempId]; + + List pendingFor(int chatId) => + List.unmodifiable(_pending[chatId] ?? const []); + + CachedMessage? completedFor(String tempId) => _completed[tempId]; + + bool didFail(String tempId) => _failed.contains(tempId); + + Future sendPhotos({ + required int accountId, + required int chatId, + required String tempId, + required List<({File file, GalleryItem? item})> jobs, + required String caption, + CachedMessage? placeholder, + int? scheduledTime, + }) { + return _run( + accountId: accountId, + chatId: chatId, + tempId: tempId, + placeholder: placeholder, + scheduledTime: scheduledTime, + slots: jobs.length, + upload: (progress) async { + final tokens = await _uploadPhotos(jobs, progress); + if (tokens.any((t) => t == null)) return null; + return messagesModule.sendPhotoMessage( + chatId, + tokens.cast(), + caption: caption.isEmpty ? null : caption, + scheduledTime: scheduledTime, + ); + }, + ); + } + + Future sendVideo({ + required int accountId, + required int chatId, + required String tempId, + required File file, + required String caption, + CachedMessage? placeholder, + int? scheduledTime, + }) { + return _run( + accountId: accountId, + chatId: chatId, + tempId: tempId, + placeholder: placeholder, + scheduledTime: scheduledTime, + slots: 1, + upload: (progress) async { + final info = await messagesModule.requestVideoUploadUrl(); + if (info == null || info.url.isEmpty) return null; + final ok = await fileUploader.uploadVideoFile( + Uri.parse(info.url), + file, + onProgress: (sent, total) { + if (total > 0) { + progress.value = [(sent / total).clamp(0.0, 1.0)]; + } + }, + ); + if (!ok) return null; + progress.value = const [1]; + return messagesModule.sendVideoMessage( + chatId, + info.token, + caption: caption.isEmpty ? null : caption, + scheduledTime: scheduledTime, + ); + }, + ); + } + + Future _run({ + required int accountId, + required int chatId, + required String tempId, + required int slots, + required Future?> Function( + ValueNotifier> progress, + ) + upload, + CachedMessage? placeholder, + int? scheduledTime, + }) async { + final scheduled = scheduledTime != null; + final progress = ValueNotifier>( + List.filled(slots < 1 ? 1 : slots, 0), + ); + _progress[tempId] = progress; + if (placeholder != null) { + (_pending[chatId] ??= []).add(placeholder); + } + + try { + final serverMsg = await upload(progress); + if (serverMsg == null) throw Exception('send_failed'); + + if (scheduled) { + _finish(chatId, tempId); + _events.add( + MediaSendDone( + chatId: chatId, + tempId: tempId, + scheduled: true, + scheduledTime: scheduledTime, + ), + ); + return; + } + + final real = CachedMessage.fromPushPayload(accountId, chatId, serverMsg); + try { + await AppDatabase.saveMessages([real.toDbRow()]); + if (real.id != tempId) { + await AppDatabase.deleteMessage(accountId, chatId, tempId); + } + } catch (e) { + logger.w('MediaSendService: не удалось сохранить сообщение: $e'); + } + _replaceInSessionCache(accountId, chatId, tempId, real); + _remember(tempId, real); + _finish(chatId, tempId); + _events.add( + MediaSendDone( + chatId: chatId, + tempId: tempId, + scheduled: false, + message: real, + ), + ); + } catch (e) { + logger.w('MediaSendService: $e'); + if (!scheduled) { + _replaceInSessionCache(accountId, chatId, tempId, null); + _remember(tempId, null); + } + _finish(chatId, tempId); + _events.add( + MediaSendFailed(chatId: chatId, tempId: tempId, scheduled: scheduled), + ); + } + } + + void _remember(String tempId, CachedMessage? real) { + if (_completed.length + _failed.length > _historyLimit) { + _completed.clear(); + _failed.clear(); + } + if (real == null) { + _failed.add(tempId); + } else { + _completed[tempId] = real; + } + } + + Future> _uploadPhotos( + List<({File file, GalleryItem? item})> jobs, + ValueNotifier> progress, + ) async { + final tokens = List.filled(jobs.length, null); + var nextIndex = 0; + var failed = false; + + Future worker() async { + while (!failed) { + final i = nextIndex++; + if (i >= jobs.length) return; + final token = await _uploadOnePhoto(jobs[i], i, progress); + if (token == null) { + failed = true; + return; + } + tokens[i] = token; + } + } + + final workerCount = jobs.length < _photoConcurrency + ? jobs.length + : _photoConcurrency; + await Future.wait(List.generate(workerCount, (_) => worker())); + return tokens; + } + + Future _uploadOnePhoto( + ({File file, GalleryItem? item}) job, + int index, + ValueNotifier> progress, + ) async { + File file; + try { + file = await optimizePhotoForUpload(job.file, item: job.item); + } catch (e) { + logger.w('optimize photo: $e'); + file = job.file; + } + for (var attempt = 0; attempt < _photoAttempts; attempt++) { + if (attempt > 0) { + await Future.delayed(Duration(seconds: attempt)); + _setSlot(progress, index, 0); + } + try { + final url = await messagesModule.requestPhotoUploadUrl(); + if (url == null || url.isEmpty) continue; + final token = await fileUploader.uploadPhoto( + Uri.parse(url), + file, + filename: _photoFilename(file), + onProgress: (sent, total) { + if (total <= 0) return; + _setSlot(progress, index, (sent / total).clamp(0.0, 1.0)); + }, + ); + if (token != null) return token; + } catch (e) { + logger.w('uploadOnePhoto attempt ${attempt + 1}: $e'); + } + } + return null; + } + + void _setSlot( + ValueNotifier> progress, + int index, + double value, + ) { + final next = List.from(progress.value); + if (index < next.length) { + next[index] = value; + progress.value = next; + } + } + + String _photoFilename(File file) { + final segments = file.uri.pathSegments; + final name = segments.isNotEmpty ? segments.last : ''; + return name.isNotEmpty ? name : 'photo.jpg'; + } + + void _finish(int chatId, String tempId) { + _progress.remove(tempId); + final list = _pending[chatId]; + if (list == null) return; + list.removeWhere((m) => m.id == tempId); + if (list.isEmpty) _pending.remove(chatId); + } + + void _replaceInSessionCache( + int accountId, + int chatId, + String tempId, + CachedMessage? real, + ) { + final cached = MessageSessionCache.get(accountId, chatId); + if (cached == null) return; + final list = List.of(cached.messages); + final idx = list.indexWhere((m) => m.id == tempId); + if (idx == -1) return; + list[idx] = real ?? list[idx].copyWith(status: 'error'); + MessageSessionCache.save( + accountId, + chatId, + list, + reachedStart: cached.reachedStart, + ); + } +} diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index d321cb9..98b24af 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -297,6 +297,8 @@ class ReplyInfo { this.attachments, }); + bool get missing => previewText().isEmpty; + static ReplyInfo? fromPayload(Map? payload) { if (payload == null) return null; final link = payload['link']; diff --git a/lib/backend/modules/stories.dart b/lib/backend/modules/stories.dart index 4955f68..b08963a 100644 --- a/lib/backend/modules/stories.dart +++ b/lib/backend/modules/stories.dart @@ -123,6 +123,7 @@ class StoriesModule { if (acc == null) return; final map = {}; _peerStories.forEach((ownerId, stories) { + if (!_previews.containsKey(ownerId)) return; map['$ownerId'] = stories.map((s) => s.toJson()).toList(); }); await AppDatabase.setSyncValue(acc, _peersKey, jsonEncode(map)); @@ -147,6 +148,11 @@ class StoriesModule { int? lastViewedStoryId(int ownerId) => _lastViewed[ownerId]; + void clearLastViewed(int ownerId) { + if (_lastViewed.remove(ownerId) == null) return; + unawaited(_persistProgress()); + } + /// Кольца-превью, отсортированные: сначала непрочитанные, затем по времени. List get previews { final list = _previews.values.where((p) => !p.isEmpty).toList(); @@ -161,6 +167,83 @@ class StoriesModule { StoryPreview? previewFor(int ownerId) => _previews[ownerId]; + final Map _peerPreviews = {}; + final Set _requestedOwners = {}; + + static const int _ownersChunk = 20; + + StoryPreview? previewOf(int ownerId) { + final feed = _previews[ownerId]; + if (feed != null) return feed.isEmpty ? null : feed; + final peer = _peerPreviews[ownerId]; + return (peer == null || peer.isEmpty) ? null : peer; + } + + Future loadOwnersPreviews(List ownerIds) async { + if (_api.state != SessionState.online) return; + final missing = ownerIds + .where((id) => id > 0 && !_requestedOwners.contains(id)) + .toSet() + .toList(); + if (missing.isEmpty) return; + _requestedOwners.addAll(missing); + + var changed = false; + for (var i = 0; i < missing.length; i += _ownersChunk) { + final end = i + _ownersChunk > missing.length + ? missing.length + : i + _ownersChunk; + final chunk = missing.sublist(i, end); + try { + final packet = await _api.sendRequest(Opcode.storiesGetByOwner, { + 'owners': [ + for (final id in chunk) StoryOwner(ownerId: id).toMap(), + ], + }, silent: true); + if (packet.isError) continue; + if (_applyOwnerPayload(packet.payload, chunk)) changed = true; + } catch (e) { + logger.w('StoriesModule.loadOwnersPreviews: $e'); + } + } + if (changed) _bump(); + } + + bool _applyOwnerPayload(Object? data, List requested) { + if (data is! Map) return false; + var changed = false; + final seen = {}; + final rawPreviews = data['storiesPreviews']; + if (rawPreviews is List) { + for (final raw in rawPreviews) { + final preview = StoryPreview.fromMap(raw); + if (preview == null) continue; + final id = preview.owner.ownerId; + seen.add(id); + if (preview.isEmpty) { + _peerPreviews.remove(id); + } else { + _peerPreviews[id] = preview; + } + _refreshPreview(preview); + changed = true; + } + } + for (final id in requested) { + if (!seen.contains(id) && _peerPreviews.remove(id) != null) changed = true; + } + final rawPeers = data['peerStories']; + if (rawPeers is List) { + for (final raw in rawPeers) { + final peer = PeerStories.fromMap(raw); + if (peer == null) continue; + _peerStories[peer.owner.ownerId] = peer.stories; + changed = true; + } + } + return changed; + } + List? cachedStories(int ownerId) => _peerStories[ownerId]; /// Подписка на серверные пуши обновления колец (NOTIF_STORIES_UPDATE). @@ -180,6 +263,11 @@ class StoriesModule { unawaited(_persistPreviews()); } + void _refreshPreview(StoryPreview preview) { + if (!_previews.containsKey(preview.owner.ownerId)) return; + _applyPreview(preview); + } + void _applyPreview(StoryPreview preview) { if (preview.isEmpty) { _previews.remove(preview.owner.ownerId); @@ -234,7 +322,7 @@ class StoriesModule { if (rawPreviews is List) { for (final raw in rawPreviews) { final preview = StoryPreview.fromMap(raw); - if (preview != null) _applyPreview(preview); + if (preview != null) _refreshPreview(preview); } } @@ -258,6 +346,31 @@ class StoriesModule { } } + Future loadOwnerPreview(StoryOwner owner) async { + final cached = _previews[owner.ownerId]; + if (_api.state != SessionState.online) return cached; + try { + final packet = await _api.sendRequest(Opcode.storiesGetByOwner, { + 'owners': [owner.toMap()], + }); + throwIfPacketError(packet); + final data = packet.payload; + if (data is! Map) return cached; + + _applyOwnerPayload(data, [owner.ownerId]); + _requestedOwners.add(owner.ownerId); + final own = previewOf(owner.ownerId); + + _bump(); + unawaited(_persistPreviews()); + unawaited(_persistPeers()); + return own; + } catch (e) { + logger.w('StoriesModule.loadOwnerPreview: $e'); + return cached; + } + } + /// Отметить историю просмотренной. Оптимистично поднимает readCount кольца. Future mark(StoryOwner owner, int storyId) async { if (_api.state != SessionState.online) return false; diff --git a/lib/backend/modules/webapp.dart b/lib/backend/modules/webapp.dart index 4b078c7..143a304 100644 --- a/lib/backend/modules/webapp.dart +++ b/lib/backend/modules/webapp.dart @@ -13,6 +13,11 @@ abstract class EntryBannerApps { }; } +const Set kMiniAppOptions = {'HAS_WEBAPP', 'HAS_WEB_APP', 'WEBAPP'}; + +bool hasMiniAppOption(Set? options) => + options != null && options.any(kMiniAppOptions.contains); + class WebAppLaunch { final String url; diff --git a/lib/core/config/app_stories.dart b/lib/core/config/app_stories.dart index e375584..169d317 100644 --- a/lib/core/config/app_stories.dart +++ b/lib/core/config/app_stories.dart @@ -4,7 +4,7 @@ import 'persisted_setting.dart'; class AppStories { static const prefKey = 'dev_stories'; - static const bool defaultValue = false; + static const bool defaultValue = true; static final _setting = PersistedSetting( prefKey: prefKey, diff --git a/lib/core/media/desktop_video_probe.dart b/lib/core/media/desktop_video_probe.dart new file mode 100644 index 0000000..529abf4 --- /dev/null +++ b/lib/core/media/desktop_video_probe.dart @@ -0,0 +1,127 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +class DesktopVideoProbe { + static const Duration _timeout = Duration(seconds: 6); + static const int _maxCache = 60; + + static bool get supported => + !Platform.isAndroid && !Platform.isIOS && !Platform.isFuchsia; + + static bool? _hasTools; + static final Map _durations = {}; + static final Map _thumbs = {}; + + static Future _toolsAvailable() async { + if (_hasTools != null) return _hasTools!; + if (!supported) return _hasTools = false; + try { + final probe = await Process.run('ffprobe', const [ + '-version', + ]).timeout(_timeout); + _hasTools = probe.exitCode == 0; + } catch (_) { + _hasTools = false; + } + return _hasTools!; + } + + static Future duration(String path) async { + if (_durations.containsKey(path)) return _durations[path]; + if (!await _toolsAvailable()) return null; + Duration? result; + try { + final out = await Process.run('ffprobe', [ + '-v', + 'error', + '-show_entries', + 'format=duration', + '-of', + 'default=noprint_wrappers=1:nokey=1', + path, + ]).timeout(_timeout); + final seconds = double.tryParse('${out.stdout}'.trim()); + if (seconds != null && seconds > 0) { + result = Duration(milliseconds: (seconds * 1000).round()); + } + } catch (_) {} + _remember(_durations, path, result); + return result; + } + + static final Map _sizes = {}; + + static Future<(int, int)?> dimensions(String path) async { + if (_sizes.containsKey(path)) return _sizes[path]; + if (!await _toolsAvailable()) return null; + (int, int)? result; + try { + final out = await Process.run('ffprobe', [ + '-v', + 'error', + '-select_streams', + 'v:0', + '-show_entries', + 'stream=width,height', + '-of', + 'csv=s=x:p=0', + path, + ]).timeout(_timeout); + final parts = '${out.stdout}'.trim().split('x'); + if (parts.length >= 2) { + final w = int.tryParse(parts[0].trim()); + final h = int.tryParse(parts[1].trim()); + if (w != null && h != null && w > 0 && h > 0) result = (w, h); + } + } catch (_) {} + _remember(_sizes, path, result); + return result; + } + + static Future thumbnail(String path, int size) async { + final key = '$path@$size'; + if (_thumbs.containsKey(key)) return _thumbs[key]; + if (!await _toolsAvailable()) return null; + var bytes = await _grabFrame(path, size, '1'); + bytes ??= await _grabFrame(path, size, '0'); + _remember(_thumbs, key, bytes); + return bytes; + } + + static Future _grabFrame( + String path, + int size, + String seek, + ) async { + try { + final out = await Process.run('ffmpeg', [ + '-v', + 'error', + '-ss', + seek, + '-i', + path, + '-frames:v', + '1', + '-vf', + 'scale=$size:-2:force_original_aspect_ratio=decrease', + '-f', + 'image2', + '-vcodec', + 'mjpeg', + 'pipe:1', + ], stdoutEncoding: null).timeout(_timeout); + final data = out.stdout; + if (data is List && data.isNotEmpty) { + return Uint8List.fromList(data); + } + } catch (_) {} + return null; + } + + static void _remember(Map cache, String key, T value) { + if (cache.length > _maxCache) cache.clear(); + cache[key] = value; + } +} diff --git a/lib/core/media/gallery_source.dart b/lib/core/media/gallery_source.dart index fae22ab..81e5086 100644 --- a/lib/core/media/gallery_source.dart +++ b/lib/core/media/gallery_source.dart @@ -1,9 +1,12 @@ +import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:photo_manager/photo_manager.dart'; +import 'desktop_video_probe.dart'; + enum GalleryPermission { granted, limited, denied } abstract class GalleryItem { @@ -134,17 +137,37 @@ class _AssetGalleryItem implements GalleryItem { } } +const Set kGalleryImageExtensions = { + '.jpg', + '.jpeg', + '.png', + '.gif', + '.webp', + '.bmp', + '.heic', + '.heif', +}; + +const Set kGalleryVideoExtensions = { + '.mp4', + '.mov', + '.m4v', + '.mkv', + '.webm', + '.avi', + '.3gp', +}; + +String _fileExtension(String path) { + final dot = path.lastIndexOf('.'); + if (dot < 0) return ''; + return path.substring(dot).toLowerCase(); +} + +bool isVideoPath(String path) => + kGalleryVideoExtensions.contains(_fileExtension(path)); + class _DesktopGallerySource implements GallerySource { - static const _imageExtensions = { - '.jpg', - '.jpeg', - '.png', - '.gif', - '.webp', - '.bmp', - '.heic', - '.heif', - }; @override Future ensurePermission() async => @@ -157,13 +180,30 @@ class _DesktopGallerySource implements GallerySource { if (!dir.existsSync()) continue; try { for (final entity in dir.listSync(followLinks: false)) { - if (entity is! File || !_isImage(entity.path)) continue; + if (entity is! File || !_isMedia(entity.path)) continue; entries.add((file: entity, modified: entity.statSync().modified)); } } catch (_) {} } entries.sort((a, b) => b.modified.compareTo(a.modified)); - return entries.take(limit).map((e) => _FileGalleryItem(e.file)).toList(); + final items = entries + .take(limit) + .map((e) => _FileGalleryItem(e.file)) + .toList(); + const batch = 8; + const eager = 24; + + Future probeRange(int from, int to) async { + for (var i = from; i < to; i += batch) { + final end = i + batch > to ? to : i + batch; + await Future.wait(items.sublist(i, end).map((it) => it.probe())); + } + } + + final head = items.length < eager ? items.length : eager; + await probeRange(0, head); + if (head < items.length) unawaited(probeRange(head, items.length)); + return items; } @override @@ -180,35 +220,45 @@ class _DesktopGallerySource implements GallerySource { Directory('$home/Pictures'), Directory('$home/Изображения'), Directory('$home/Images'), + Directory('$home/Videos'), + Directory('$home/Видео'), + Directory('$home/Movies'), ]; } - bool _isImage(String path) { - final dot = path.lastIndexOf('.'); - if (dot < 0) return false; - return _imageExtensions.contains(path.substring(dot).toLowerCase()); + bool _isMedia(String path) { + final ext = _fileExtension(path); + return kGalleryImageExtensions.contains(ext) || + kGalleryVideoExtensions.contains(ext); } } class _FileGalleryItem implements GalleryItem { final File file; + Duration? _duration; - _FileGalleryItem(this.file); + _FileGalleryItem(this.file, {Duration? duration}) : _duration = duration; + + Future probe() async { + if (!isVideo || _duration != null) return; + _duration = await DesktopVideoProbe.duration(file.path); + } @override String get id => file.path; @override - bool get isVideo => false; + bool get isVideo => isVideoPath(file.path); @override - Duration? get duration => null; + Duration? get duration => _duration; @override File? get localFile => file; @override - Future thumbnail(int size) async => null; + Future thumbnail(int size) async => + isVideo ? DesktopVideoProbe.thumbnail(file.path, size) : null; @override Future originFile() async => file; @@ -220,5 +270,7 @@ class _FileGalleryItem implements GalleryItem { }) async => null; @override - Future<(int, int)?> dimensions() => imageFileDimensions(file); + Future<(int, int)?> dimensions() => isVideo + ? DesktopVideoProbe.dimensions(file.path) + : imageFileDimensions(file); } diff --git a/lib/core/media/preview_image.dart b/lib/core/media/preview_image.dart new file mode 100644 index 0000000..34567ff --- /dev/null +++ b/lib/core/media/preview_image.dart @@ -0,0 +1,20 @@ +import 'dart:convert'; + +import 'package:flutter/widgets.dart'; + +final Expando _providers = Expando('preview'); + +ImageProvider? dataUriImage(Object owner, String? data) { + if (data == null || !data.startsWith('data:')) return null; + final cached = _providers[owner]; + if (cached != null) return cached; + final comma = data.indexOf(','); + if (comma < 0) return null; + try { + final provider = MemoryImage(base64Decode(data.substring(comma + 1))); + _providers[owner] = provider; + return provider; + } catch (_) { + return null; + } +} diff --git a/lib/frontend/screens/chats/chat/view/chat_header.dart b/lib/frontend/screens/chats/chat/view/chat_header.dart index 6a93a36..0592d69 100644 --- a/lib/frontend/screens/chats/chat/view/chat_header.dart +++ b/lib/frontend/screens/chats/chat/view/chat_header.dart @@ -1,12 +1,21 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:komet/core/config/app_frost.dart'; +import 'package:komet/core/config/app_stories.dart'; +import 'package:komet/core/utils/haptics.dart'; +import 'package:komet/frontend/screens/stories/story_owner_info.dart'; +import 'package:komet/frontend/screens/stories/story_ring.dart'; +import 'package:komet/frontend/screens/stories/story_viewer_screen.dart'; import 'package:komet/frontend/widgets/encryption_lock_badge.dart'; import 'package:komet/frontend/widgets/glossy_pill.dart'; import 'package:komet/frontend/widgets/online_dot.dart'; import 'package:komet/frontend/widgets/profile_hero.dart'; +import 'package:komet/main.dart' show storiesModule; +import 'package:komet/models/story.dart'; class ChatHeaderRow extends StatelessWidget { final bool glossy; @@ -120,12 +129,11 @@ class ChatHeaderRow extends StatelessWidget { children: [ _withOnlineDot( cs, - ProfileHeroAvatar( - tag: heroTag, - size: 44, - child: imageUrl.isNotEmpty + _heroAvatar( + 44, + (d) => imageUrl.isNotEmpty ? CircleAvatar( - radius: 22, + radius: d / 2, backgroundImage: CachedNetworkImageProvider( imageUrl, maxWidth: 144, @@ -133,13 +141,13 @@ class ChatHeaderRow extends StatelessWidget { ), ) : CircleAvatar( - radius: 22, + radius: d / 2, backgroundColor: cs.primaryContainer, child: Text( name.isNotEmpty ? name[0].toUpperCase() : '?', style: TextStyle( color: cs.onPrimaryContainer, - fontSize: 16, + fontSize: d * 0.36, fontWeight: FontWeight.w600, fontFamily: 'Outfit', ), @@ -287,12 +295,11 @@ class ChatHeaderRow extends StatelessWidget { children: [ _withOnlineDot( cs, - ProfileHeroAvatar( - tag: heroTag, - size: 36, - child: imageUrl.isNotEmpty + _heroAvatar( + 36, + (d) => imageUrl.isNotEmpty ? CircleAvatar( - radius: 18, + radius: d / 2, backgroundImage: CachedNetworkImageProvider( imageUrl, maxWidth: 144, @@ -300,13 +307,13 @@ class ChatHeaderRow extends StatelessWidget { ), ) : CircleAvatar( - radius: 18, + radius: d / 2, backgroundColor: cs.primaryContainer, child: Text( name.isNotEmpty ? name[0].toUpperCase() : '?', style: TextStyle( color: cs.onPrimaryContainer, - fontSize: 12, + fontSize: d / 3, ), ), ), @@ -395,6 +402,67 @@ class ChatHeaderRow extends StatelessWidget { ); } + int get _storyOwnerId => chatType == 'DIALOG' ? chatId ^ myId : chatId; + + Widget _heroAvatar( + double size, + Widget Function(double diameter) avatarBuilder, + ) { + final ownerId = _storyOwnerId; + if (!AppStories.current.value || ownerId <= 0) { + return ProfileHeroAvatar( + tag: heroTag, + size: size, + child: avatarBuilder(size), + ); + } + const gap = 3.0; + return ValueListenableBuilder( + valueListenable: storiesModule.storiesChanged, + builder: (context, _, _) { + final preview = storiesModule.previewFor(ownerId); + final hasStory = preview != null && !preview.isEmpty; + final inner = hasStory ? size - gap * 2 : size; + return GestureDetector( + behavior: hasStory + ? HitTestBehavior.opaque + : HitTestBehavior.deferToChild, + onTap: hasStory ? () => _openStories(context, preview) : null, + child: StoryAvatarRing( + diameter: inner, + total: preview?.totalCount ?? 0, + read: preview?.readCount ?? 0, + strokeWidth: 2, + ringGap: hasStory ? gap : 0, + haloWidth: 1.2, + child: ProfileHeroAvatar( + tag: heroTag, + size: inner, + child: avatarBuilder(inner), + ), + ), + ); + }, + ); + } + + void _openStories(BuildContext context, StoryPreview preview) { + Haptics.tap(); + unawaited( + openStoryViewer( + context, + previews: [preview], + origin: storyOriginOf(context), + ownerOverrides: { + preview.owner.ownerId: StoryOwnerInfo( + name: name, + avatarUrl: imageUrl.isEmpty ? null : imageUrl, + ), + }, + ), + ); + } + Widget _withOnlineDot(ColorScheme cs, Widget avatar, {double dotSize = 12}) { final otherId = chatId ^ myId; final showDot = chatType == 'DIALOG' && myId != 0 && otherId > 0; diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index 3b4443a..f309523 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -1,4 +1,9 @@ +import 'dart:async'; +import 'dart:math' as math; +import 'dart:ui' show lerpDouble; + import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart' show listEquals; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:komet/main.dart'; @@ -10,11 +15,14 @@ import '../../../backend/modules/messages.dart' show ContactCache; import '../../../core/cache/info_cache.dart'; import '../../../core/calls/call_controller.dart'; import '../../../core/config/app_show_extra_info.dart'; +import '../../../core/config/app_stories.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/utils/format.dart'; +import '../../../core/utils/haptics.dart'; import '../../../l10n/app_localizations.dart'; import '../../../models/chat_info.dart'; import '../../../models/contact_info.dart'; +import '../../../models/story.dart'; import '../../widgets/animated_text_swap.dart'; import '../../widgets/avatar_history_screen.dart'; import '../../widgets/chat_info/shared_content_tabs.dart'; @@ -24,11 +32,16 @@ import '../../widgets/formatted_message_text.dart'; import '../../widgets/reload_on_reconnect.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/komet_avatar.dart'; +import '../../widgets/profile_header_scroll.dart'; import '../../widgets/profile_hero.dart'; import '../../widgets/swipe_route.dart'; import '../../../backend/modules/chats.dart'; import '../calls/call_screen.dart'; import '../contacts/open_contact_profile.dart'; +import '../stories/story_owner_info.dart'; +import '../stories/story_peanut.dart'; +import '../stories/story_ring.dart'; +import '../stories/story_viewer_screen.dart'; import 'chat_screen.dart'; import 'group_invite_sheets.dart'; import 'profile_action_sheets.dart'; @@ -98,7 +111,7 @@ class ChatInfoScreen extends StatefulWidget { class _ChatInfoScreenState extends State with ReloadOnReconnect { final _tabScrollController = ScrollController(); - final _bodyScrollController = ScrollController(); + ScrollController? _bodyScrollController; int _myId = 0; bool _isLoading = true; @@ -136,17 +149,37 @@ class _ChatInfoScreenState extends State bool _muteBusy = false; bool _addContactBusy = false; + StoryPreview? _storyPreview; + List _unreadStories = const []; + final GlobalKey _avatarKey = GlobalKey(); + + final PageController _avatarPageController = PageController(); + List _avatarPages = const []; + int _avatarIndex = 0; + int _avatarTotal = 0; + + double _headerDelta = 0; + bool _expandArmed = false; + bool _headerEverExpanded = false; + @override void initState() { super.initState(); - _bodyScrollController.addListener(_onBodyScroll); + storiesModule.storiesChanged.addListener(_onStoriesChanged); _load(); } + void _onStoriesChanged() { + if (!mounted) return; + setState(_refreshUnreadStories); + } + @override void dispose() { + storiesModule.storiesChanged.removeListener(_onStoriesChanged); _tabScrollController.dispose(); - _bodyScrollController.dispose(); + _bodyScrollController?.dispose(); + _avatarPageController.dispose(); super.dispose(); } @@ -261,6 +294,8 @@ class _ChatInfoScreenState extends State } if (!_isBot && _otherId != _myId) _loadBlockedState(_otherId!); + if (!_isBot) unawaited(_loadStories(_otherId!)); + unawaited(_loadAvatarHistory(_otherId!)); } } else if (info == null) { setState(() => _isLoading = false); @@ -394,13 +429,18 @@ class _ChatInfoScreenState extends State } var added = 0; + final fresh = []; for (final e in page.members) { if (_seenMemberIds.add(e.id)) { _addMember(_memberFrom(e)); + fresh.add(e.id); added++; } } if (added > 0) _rebuildMembers(); + if (fresh.isNotEmpty && AppStories.current.value) { + unawaited(storiesModule.loadOwnersPreviews(fresh)); + } final total = _chatInfo?.participantsCount; if (page.members.isEmpty || @@ -419,7 +459,9 @@ class _ChatInfoScreenState extends State if (_membersLoading || _membersEnd) return; if (_selectedTab != AppLocalizations.of(context)!.chatInfoTabMembers) return; - final pos = _bodyScrollController.position; + final controller = _bodyScrollController; + if (controller == null || !controller.hasClients) return; + final pos = controller.position; if (pos.pixels >= pos.maxScrollExtent - 400) { _fetchMembersPage(); } @@ -473,26 +515,430 @@ class _ChatInfoScreenState extends State backgroundColor: cs.surface, floatingActionButtonLocation: FloatingActionButtonLocation.startFloat, floatingActionButton: const ConnectionSpinner(), - body: SafeArea(child: _buildScrollBody(cs)), + body: _buildScrollBody(cs), ); } + static const double _headerAvatarSize = 96; + static const double _headerCollapsedBody = 232; + static const double _headerVignette = 64; + + bool get _headerHasPhoto => widget.imageUrl.isNotEmpty && !_peerDeleted; + Widget _buildScrollBody(ColorScheme cs) { - return CustomScrollView( - controller: _bodyScrollController, - slivers: [ - SliverAppBar( - backgroundColor: Colors.transparent, - elevation: 0, - floating: true, - leading: IconButton( - icon: Icon(Icons.arrow_back, color: cs.onSurface), - onPressed: () => Navigator.pop(context), + return LayoutBuilder( + builder: (context, viewport) { + final media = MediaQuery.of(context); + final topPad = media.padding.top; + final collapsedH = topPad + _headerCollapsedBody; + final expandedH = _headerHasPhoto + ? math.max( + collapsedH, + math.min(media.size.width, viewport.maxHeight * 0.62), + ) + : collapsedH; + final delta = expandedH - collapsedH; + _syncHeaderDelta(delta); + final controller = _bodyScrollController ??= + (ScrollController(initialScrollOffset: delta) + ..addListener(_onBodyScroll)); + + return NotificationListener( + onNotification: (n) => _onHeaderScrollNotification(n, delta), + child: CustomScrollView( + key: ValueKey(delta), + controller: controller, + physics: HeaderPullScrollPhysics( + delta: delta, + isArmed: () => _expandArmed, + parent: const BouncingScrollPhysics(), + ), + slivers: [ + SliverPersistentHeader( + delegate: MorphHeaderDelegate( + collapsedExtent: collapsedH, + expandedExtent: expandedH, + headerBuilder: (ctx, t) => + _buildMorphHeader(ctx, cs, _headerHasPhoto ? t : 0.0), + ), + ), + SliverToBoxAdapter( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: math.max(0, viewport.maxHeight - collapsedH), + ), + child: _buildBody(cs), + ), + ), + ], + ), + ); + }, + ); + } + + void _syncHeaderDelta(double delta) { + if (_headerDelta == delta) return; + final prev = _headerDelta; + _headerDelta = delta; + if (_bodyScrollController == null) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + final c = _bodyScrollController; + if (!mounted || c == null || !c.hasClients) return; + final target = (c.offset + (delta - prev)).clamp( + 0.0, + c.position.maxScrollExtent, + ); + c.jumpTo(target); + }); + } + + bool _onHeaderScrollNotification(ScrollNotification n, double delta) { + if (n.depth != 0) return false; + if (n is ScrollStartNotification) { + if (n.dragDetails != null) { + _expandArmed = delta > 0 && n.metrics.pixels <= delta + 8; + } + } else if (n is ScrollEndNotification) { + _snapHeader(delta); + } + return false; + } + + void _snapHeader(double delta) { + final c = _bodyScrollController; + if (c == null || !c.hasClients || delta <= 0) return; + final offset = c.offset; + if (offset <= 0 || offset >= delta) return; + final target = (offset < delta / 2 ? 0.0 : delta).clamp( + 0.0, + c.position.maxScrollExtent, + ); + if ((target - offset).abs() < 1) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !c.hasClients) return; + c.animateTo( + target, + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ); + }); + } + + Widget _buildMorphHeader(BuildContext context, ColorScheme cs, double t) { + final topPad = MediaQuery.paddingOf(context).top; + if (t > 0) _headerEverExpanded = true; + final iconColor = Color.lerp(cs.onSurface, Colors.white, t)!; + final nameColor = Color.lerp(cs.onSurface, Colors.white, t)!; + final subColor = Color.lerp( + cs.onSurfaceVariant, + Colors.white.withValues(alpha: 0.85), + t, + )!; + final ringOpacity = (1 - t * 3).clamp(0.0, 1.0); + final chipOpacity = ((t - 0.3) / 0.5).clamp(0.0, 1.0); + final unread = _unreadStories; + final totalPhotos = math.max(_avatarTotal, _avatarPages.length); + + return ClipRect( + child: LayoutBuilder( + builder: (context, constraints) { + final w = constraints.maxWidth; + final h = constraints.maxHeight; + const size = _headerAvatarSize; + final avatarRect = Rect.lerp( + Rect.fromLTWH((w - size) / 2, topPad + 52, size, size), + Rect.fromLTWH(0, 0, w, h), + t, + )!; + final radius = lerpDouble(size / 2, 0, t)!; + + return Stack( + clipBehavior: Clip.hardEdge, + children: [ + Positioned.fromRect( + rect: avatarRect, + child: _headerAvatar(cs, radius, t), + ), + if (_headerHasPhoto) ...[ + Positioned( + left: 0, + right: 0, + top: 0, + height: topPad + 72, + child: IgnorePointer( + child: Opacity( + opacity: t, + child: const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.black45, Colors.transparent], + ), + ), + ), + ), + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + height: 170, + child: IgnorePointer( + child: Opacity( + opacity: t, + child: const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black54], + stops: [0.0, 0.62], + ), + ), + ), + ), + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + height: _headerVignette, + child: IgnorePointer( + child: Opacity( + opacity: t, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + cs.surface.withValues(alpha: 0), + cs.surface.withValues(alpha: 0.55), + cs.surface, + ], + stops: const [0.0, 0.55, 1.0], + ), + ), + ), + ), + ), + ), + ], + Positioned.fromRect( + rect: avatarRect.inflate(7 * (1 - t)), + child: IgnorePointer( + child: Opacity( + opacity: ringOpacity, + child: CustomPaint( + painter: _storyPreview == null + ? null + : SegmentedRingPainter( + total: _storyPreview!.totalCount, + read: _storyPreview!.readCount, + unreadColors: [cs.primary, cs.tertiary, cs.primary], + readColor: cs.outlineVariant, + strokeWidth: 3.4, + ), + ), + ), + ), + ), + Positioned( + left: 4, + right: 4, + top: topPad + 4, + child: Row( + children: [ + IconButton( + icon: Icon(Icons.arrow_back, color: iconColor), + onPressed: () => Navigator.pop(context), + ), + Expanded( + child: chipOpacity > 0 && unread.isNotEmpty + ? Align( + alignment: Alignment.centerLeft, + child: Opacity( + opacity: chipOpacity, + child: _storyChip(unread), + ), + ) + : const SizedBox.shrink(), + ), + if (chipOpacity > 0 && totalPhotos > 1) + Opacity( + opacity: chipOpacity, + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: Text( + '${_avatarIndex + 1}/$totalPhotos', + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + _buildMoreButton(cs, iconColor), + ], + ), + ), + Positioned( + left: 0, + right: 0, + bottom: lerpDouble(16, 18, t)!, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _headerAligned(t, _buildNameRow(cs, nameColor, t)), + const SizedBox(height: 2), + _headerAligned( + t, + SelectionArea( + child: Text( + _subtitle(), + style: TextStyle(color: subColor, fontSize: 14), + ), + ), + ), + ], + ), + ), + ], + ); + }, + ), + ); + } + + Widget _headerAligned(double t, Widget child) { + return Align( + alignment: Alignment.lerp(Alignment.center, Alignment.centerLeft, t)!, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: lerpDouble(12, 18, t)!), + child: child, + ), + ); + } + + Widget _headerAvatar(ColorScheme cs, double radius, double t) { + final expanded = t > 0.5; + final openHistory = _headerHasPhoto + ? () => AvatarHistoryScreen.open( + context, + contactId: _otherId ?? widget.dialogPeerId ?? 0, + name: widget.name, + currentAvatarUrl: _avatarPages.isEmpty + ? widget.imageUrl + : _avatarPages[_avatarIndex.clamp(0, _avatarPages.length - 1)], + ) + : null; + final openStories = _storyPreview == null ? null : _openStories; + + return KeyedSubtree( + key: _avatarKey, + child: ProfileHeroAvatar( + tag: widget.heroTag, + size: _headerAvatarSize, + child: GestureDetector( + onTap: expanded ? openHistory : (openStories ?? openHistory), + onLongPress: expanded ? null : (openStories == null ? null : openHistory), + child: ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: _headerAvatarContent(cs, t), ), - actions: [_buildMoreButton(cs)], ), - SliverToBoxAdapter(child: _buildBody(cs)), - ], + ), + ); + } + + Widget _headerAvatarContent(ColorScheme cs, double t) { + if (_peerDeleted) { + return _ghostAvatar(radius: _headerAvatarSize / 2, fontSize: 52); + } + final pages = _avatarPages.isNotEmpty + ? _avatarPages + : (widget.imageUrl.isEmpty ? const [] : [widget.imageUrl]); + if (pages.isEmpty) { + return KometAvatar( + name: widget.name, + size: _headerAvatarSize, + fontSize: 36, + fadeIn: false, + ); + } + return PageView.builder( + controller: _avatarPageController, + itemCount: pages.length, + physics: t > 0.99 + ? const PageScrollPhysics() + : const NeverScrollableScrollPhysics(), + onPageChanged: (i) => setState(() => _avatarIndex = i), + itemBuilder: (_, i) => CachedNetworkImage( + imageUrl: pages[i], + fit: BoxFit.cover, + memCacheWidth: _headerEverExpanded ? 720 : 288, + fadeInDuration: const Duration(milliseconds: 150), + errorWidget: (_, _, _) => ColoredBox( + color: cs.surfaceContainerHigh, + child: Center( + child: Text( + widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 32), + ), + ), + ), + ), + ); + } + + void _refreshUnreadStories() { + final preview = _storyPreview; + if (preview == null || preview.unreadCount <= 0) { + _unreadStories = const []; + return; + } + final stories = storiesModule.cachedStories(preview.owner.ownerId); + if (stories == null || stories.isEmpty) { + _unreadStories = const []; + return; + } + final from = (stories.length - preview.unreadCount).clamp( + 0, + stories.length, + ); + _unreadStories = stories.sublist(from); + } + + Widget _storyChip(List unread) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _openStories, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + StoryPeanut(stories: unread), + const SizedBox(width: 8), + Flexible( + child: Text( + '${unread.length} ' + '${pluralRu(unread.length, 'история', 'истории', 'историй')}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 17, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ), + ), + ], + ), ); } @@ -502,29 +948,16 @@ class _ChatInfoScreenState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ - const SizedBox(height: 4), - _avatar(), - const SizedBox(height: 14), - _buildNameRow(cs), - const SizedBox(height: 4), if (_isLoading) ..._loadingBlocks(cs) else ...[ - SelectionArea( - child: Text( - _subtitle(), - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), - textAlign: TextAlign.center, - ), - ), - const SizedBox(height: 20), _buildActions(cs), const SizedBox(height: 16), _buildPersistentInfo(cs), _buildTabBar(cs), const SizedBox(height: 12), _buildTabContent(cs), - const SizedBox(height: 40), + SizedBox(height: 40 + MediaQuery.paddingOf(context).bottom), ], ], ), @@ -553,16 +986,17 @@ class _ChatInfoScreenState extends State return widget.name; } - Widget _buildMoreButton(ColorScheme cs) { + Widget _buildMoreButton(ColorScheme cs, [Color? iconColor]) { final entries = _moreMenuEntries(); + final color = iconColor ?? cs.onSurface; if (entries.isEmpty) { return IconButton( - icon: Icon(Icons.more_vert, color: cs.onSurface), + icon: Icon(Icons.more_vert, color: color), onPressed: null, ); } return PopupMenuButton( - icon: Icon(Icons.more_vert, color: cs.onSurface), + icon: Icon(Icons.more_vert, color: color), onSelected: (action) => action(), itemBuilder: (_) => [ for (final entry in entries) @@ -675,10 +1109,10 @@ class _ChatInfoScreenState extends State return null; } - Widget _buildNameRow(ColorScheme cs) { + Widget _buildNameRow(ColorScheme cs, Color textColor, double t) { final nameStyle = TextStyle( - color: cs.onSurface, - fontSize: 22, + color: textColor, + fontSize: lerpDouble(22, 25, t)!, fontWeight: FontWeight.w700, fontFamily: 'Outfit', ); @@ -687,9 +1121,10 @@ class _ChatInfoScreenState extends State final hasToggle = _isContact && real != null && real != custom; return Row( + mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ - const SizedBox(width: 36), + SizedBox(width: (hasToggle ? 30.0 : 0.0) * (1 - t)), Flexible( child: SelectionArea( child: ProfileHeroName( @@ -703,24 +1138,34 @@ class _ChatInfoScreenState extends State real ?? custom, style: nameStyle, textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), child: Text( custom, style: nameStyle, textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ), ), ), ), SizedBox( - width: 36, + width: hasToggle ? 30 : 0, + height: 28, child: hasToggle ? IconButton( padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, + constraints: const BoxConstraints( + minWidth: 28, + minHeight: 28, + ), iconSize: 20, - color: _showRealName ? cs.primary : cs.onSurfaceVariant, + color: _showRealName + ? Color.lerp(cs.primary, Colors.white, t) + : textColor.withValues(alpha: 0.7), icon: Icon( _showRealName ? Symbols.visibility : Symbols.visibility_off, ), @@ -1183,7 +1628,7 @@ class _ChatInfoScreenState extends State items.add(_simpleInfoCard(cs, l10n.chatInfoBio, bio)); } } - } else if (widget.chatType == 'CHANNEL') { + } else { final link = _chatInfo?.link; if (link != null && link.isNotEmpty) { items.add(_linkCard(cs, link)); @@ -1520,15 +1965,6 @@ class _ChatInfoScreenState extends State Widget _buildInfoTabContent(ColorScheme cs) { final items = []; - if (widget.chatType == 'CHAT') { - final desc = _chatInfo?.description; - if (desc != null && desc.isNotEmpty) { - items - ..add(_infoCard(cs, l10n.contactProfileInfoDescription, desc)) - ..add(const SizedBox(height: 8)); - } - } - items.add(_buildInfoRowsCard(cs)); return SelectionArea( @@ -1549,36 +1985,6 @@ class _ChatInfoScreenState extends State ); } - Widget _infoCard(ColorScheme cs, String label, String value) { - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(14), - padding: const EdgeInsets.fromLTRB(16, 12, 16, 14), - depth: 6, - child: SizedBox( - width: double.infinity, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), - ), - const SizedBox(height: 4), - Text( - value, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ); - } - Widget _buildMembersTabContent(ColorScheme cs) { return Container( decoration: BoxDecoration( @@ -1699,6 +2105,11 @@ class _ChatInfoScreenState extends State ? l10n.chatInfoRoleOwner : (member.isAdmin ? l10n.chatInfoRoleAdmin : null); + final story = member.blocked || !AppStories.current.value + ? null + : storiesModule.previewOf(member.id); + final avatarRadius = story == null ? 22.0 : 19.0; + return InkWell( onTap: member.isMe ? null @@ -1714,24 +2125,13 @@ class _ChatInfoScreenState extends State children: [ if (member.blocked) _ghostAvatar() - else if (avatar != null && avatar.isNotEmpty) - CircleAvatar( - radius: 22, - backgroundImage: CachedNetworkImageProvider( - avatar, - maxWidth: 144, - maxHeight: 144, - ), - backgroundColor: cs.primaryContainer, - ) else - CircleAvatar( - radius: 22, - backgroundColor: cs.primaryContainer, - child: Text( - name.isNotEmpty ? name[0].toUpperCase() : '?', - style: TextStyle(color: cs.onPrimaryContainer, fontSize: 16), - ), + _memberAvatar( + cs, + story: story, + radius: avatarRadius, + name: name, + avatarUrl: avatar, ), const SizedBox(width: 14), Expanded( @@ -1766,6 +2166,71 @@ class _ChatInfoScreenState extends State ); } + Widget _memberAvatar( + ColorScheme cs, { + required StoryPreview? story, + required double radius, + required String name, + String? avatarUrl, + }) { + final circle = (avatarUrl != null && avatarUrl.isNotEmpty) + ? CircleAvatar( + radius: radius, + backgroundImage: CachedNetworkImageProvider( + avatarUrl, + maxWidth: 144, + maxHeight: 144, + ), + backgroundColor: cs.primaryContainer, + ) + : CircleAvatar( + radius: radius, + backgroundColor: cs.primaryContainer, + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: radius * 0.72, + ), + ), + ); + if (story == null) return circle; + return Builder( + builder: (avatarContext) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _openMemberStories(avatarContext, story, name, avatarUrl), + child: StoryAvatarRing( + diameter: radius * 2, + total: story.totalCount, + read: story.readCount, + strokeWidth: 2.2, + ringGap: 3, + haloWidth: 1.5, + child: circle, + ), + ), + ); + } + + void _openMemberStories( + BuildContext avatarContext, + StoryPreview story, + String name, + String? avatarUrl, + ) { + Haptics.tap(); + unawaited( + openStoryViewer( + context, + previews: [story], + origin: storyOriginOf(avatarContext), + ownerOverrides: { + story.owner.ownerId: StoryOwnerInfo(name: name, avatarUrl: avatarUrl), + }, + ), + ); + } + Widget _ghostAvatar({double radius = 22, double fontSize = 24}) { return CircleAvatar( radius: radius, @@ -2023,34 +2488,66 @@ class _ChatInfoScreenState extends State ); } - Widget _avatar() { - const size = 96.0; - final peerId = widget.chatType == 'DIALOG' ? _otherId : null; - final hasHistory = - peerId != null && widget.imageUrl.isNotEmpty && !_peerDeleted; - return ProfileHeroAvatar( - tag: widget.heroTag, - size: size, - child: GestureDetector( - onTap: hasHistory - ? () => AvatarHistoryScreen.open( - context, - contactId: peerId, - name: widget.name, - currentAvatarUrl: widget.imageUrl, - ) - : null, - child: _peerDeleted - ? _ghostAvatar(radius: size / 2, fontSize: 52) - : KometAvatar( - name: widget.name, - imageUrl: widget.imageUrl, - size: size, - fontSize: 36, - fadeIn: false, - ), - ), + Future _loadAvatarHistory(int peerId) async { + if (!_headerHasPhoto) return; + final cached = ContactsModule.cachedPhotos(peerId); + if (cached != null) _applyAvatarPhotos(cached); + final photos = await ContactsModule.fetchPhotos(api, peerId, count: 30); + if (!mounted) return; + _applyAvatarPhotos(photos); + } + + void _applyAvatarPhotos(ContactPhotos photos) { + final urls = []; + if (widget.imageUrl.isNotEmpty) urls.add(widget.imageUrl); + for (final url in photos.urls) { + if (url.isNotEmpty && !urls.contains(url)) urls.add(url); + } + if (urls.isEmpty || listEquals(urls, _avatarPages)) return; + setState(() { + _avatarPages = urls; + _avatarTotal = math.max(photos.total, urls.length); + _avatarIndex = _avatarIndex.clamp(0, urls.length - 1); + }); + } + + Future _loadStories(int peerId) async { + if (!AppStories.current.value || _peerDeleted) return; + final cached = storiesModule.previewOf(peerId); + if (cached != null && !cached.isEmpty && mounted) { + setState(() { + _storyPreview = cached; + _refreshUnreadStories(); + }); + } + final fresh = await storiesModule.loadOwnerPreview( + StoryOwner(ownerId: peerId), ); + if (!mounted) return; + setState(() { + _storyPreview = (fresh == null || fresh.isEmpty) ? null : fresh; + _refreshUnreadStories(); + }); + } + + Future _openStories() async { + final preview = _storyPreview; + if (preview == null) return; + Haptics.tap(); + final avatarContext = _avatarKey.currentContext; + await openStoryViewer( + context, + previews: [preview], + origin: avatarContext == null ? null : storyOriginOf(avatarContext), + ownerOverrides: { + preview.owner.ownerId: StoryOwnerInfo( + name: _customName, + avatarUrl: _contactData?.avatarUrl ?? widget.imageUrl, + ), + }, + ); + if (!mounted) return; + await _loadStories(preview.owner.ownerId); } List _loadingBlocks(ColorScheme cs) { @@ -2064,10 +2561,7 @@ class _ChatInfoScreenState extends State ); return [ - const SizedBox(height: 4), - block(110, 16, r: 6), - const SizedBox(height: 24), - block(240, 54, r: 14), + block(double.infinity, 60, r: 14), const SizedBox(height: 16), block(double.infinity, 36, r: 20), const SizedBox(height: 12), diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index d558572..65ded70 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -80,7 +80,11 @@ import '../../widgets/spectrum_tint.dart'; import '../../widgets/update_dialog.dart'; import '../stories/story_composer_screen.dart'; import '../stories/story_owner_info.dart'; +import '../../../backend/modules/webapp.dart'; +import '../../../models/story.dart'; +import '../webapp/open_mini_app.dart'; import '../stories/story_ring.dart'; +import '../../widgets/sending_clock_icon.dart'; import '../stories/story_viewer_screen.dart'; import '../downloads_screen.dart'; @@ -291,6 +295,7 @@ class _ChatListScreenState extends State _storiesDockedOpen, _storiesAnimClosing, _storiesOverscrollRevealArmed, + storiesModule.storiesChanged.value, _sessionState, identityHashCode(_profile), ]); @@ -730,6 +735,21 @@ class _ChatListScreenState extends State }; } + StoryPreview? _storyPreviewFor(int ownerId) { + if (!AppStories.current.value || ownerId == 0) return null; + final preview = storiesModule.previewFor(ownerId); + return (preview == null || preview.isEmpty) ? null : preview; + } + + void _openStoriesForOwner(int ownerId, [Offset? origin]) { + final index = storiesModule.previews.indexWhere( + (p) => p.owner.ownerId == ownerId, + ); + if (index < 0) return; + Haptics.tap(); + _openStories(index, origin); + } + void _openStories(int index, [Offset? origin]) { final previews = storiesModule.previews; if (previews.isEmpty) return; @@ -1849,6 +1869,7 @@ class _ChatListScreenState extends State previewCipherText: isPlaceholder ? null : chat.lastMsgTextOneLine, + hasMiniApp: _hasMiniApp(secondId, chat), ), ); } else { @@ -2656,6 +2677,9 @@ class _ChatListScreenState extends State } Widget _ownStatusIcon(ColorScheme cs, String status, bool read) { + if (isSendingStatus(status)) { + return SendingClockIcon(color: cs.outline, size: 14); + } IconData icon; Color color; switch (status) { @@ -2791,6 +2815,7 @@ class _ChatListScreenState extends State int? previewMessageId, String previewPrefix = '', String? previewCipherText, + bool hasMiniApp = false, }) { final cs = Theme.of(context).colorScheme; final isSelected = _selectedChats.contains(id); @@ -2839,8 +2864,16 @@ class _ChatListScreenState extends State ) : _buildPreviewLine(cs, message, messageRanges, draft, messageItalic); - final Widget avatarCircle = CircleAvatar( - radius: 24, + final storyOwnerId = chatType == 'DIALOG' + ? presenceUserId + : (int.tryParse(id) ?? 0); + final story = (_isSelectionMode || widget.forwardMode) + ? null + : _storyPreviewFor(storyOwnerId); + final avatarRadius = story == null ? 24.0 : 20.0; + + final CircleAvatar rawAvatar = CircleAvatar( + radius: avatarRadius, backgroundColor: cs.surfaceContainerHighest, backgroundImage: imageUrl.isNotEmpty ? CachedNetworkImageProvider( @@ -2852,10 +2885,34 @@ class _ChatListScreenState extends State child: imageUrl.isEmpty ? Text( name.isNotEmpty ? name[0].toUpperCase() : '?', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 20), + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: story == null ? 20 : 17, + ), ) : null, ); + + final Widget avatarCircle = story == null + ? rawAvatar + : Builder( + builder: (avatarContext) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _openStoriesForOwner( + storyOwnerId, + storyOriginOf(avatarContext), + ), + child: StoryAvatarRing( + diameter: avatarRadius * 2, + total: story.totalCount, + read: story.readCount, + strokeWidth: 2.2, + ringGap: 4, + haloWidth: 1.5, + child: rawAvatar, + ), + ), + ); return SpringyTap( key: ValueKey('chat_$id'), child: InkWell( @@ -3060,6 +3117,15 @@ class _ChatListScreenState extends State size: 16, weight: 400, ), + if (hasMiniApp) ...[ + const SizedBox(width: 8), + _miniAppButton( + cs, + botId: presenceUserId, + chatId: int.tryParse(id) ?? 0, + name: name, + ), + ], ], ), ), @@ -3075,6 +3141,42 @@ class _ChatListScreenState extends State ); } + bool _hasMiniApp(int contactId, CachedChat chat) { + if (contactId == 0 || widget.forwardMode || _isSelectionMode) return false; + final options = ContactCache.getOptions(contactId); + if (options != null && options.any(kMiniAppOptions.contains)) return true; + return chat.options.any(kMiniAppOptions.contains); + } + + Widget _miniAppButton( + ColorScheme cs, { + required int botId, + required int chatId, + required String name, + }) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => unawaited( + openMiniApp(context, botId: botId, chatId: chatId, title: name), + ), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3), + decoration: BoxDecoration( + color: cs.primary, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + AppLocalizations.of(context)!.miniAppOpen, + style: TextStyle( + color: cs.onPrimary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } + void _openAccountSwitcher(Offset point) { Haptics.medium(); final controller = AccountSwitcherController()..attach(point); diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 36b87c0..5dfdf26 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 'dart:convert' show base64Encode; import 'dart:io' show File; import 'dart:math' as math; import 'dart:ui' as ui; @@ -13,8 +14,12 @@ import 'package:komet/backend/modules/chats.dart'; import 'package:komet/backend/modules/comments.dart'; import 'package:komet/backend/modules/file_uploader.dart'; import 'package:komet/backend/modules/upload_notification_service.dart'; +import 'package:komet/backend/modules/media_send.dart'; +import 'package:komet/backend/modules/webapp.dart'; +import 'package:komet/frontend/screens/webapp/open_mini_app.dart'; +import 'package:komet/frontend/widgets/sending_clock_icon.dart'; +import 'package:komet/core/media/desktop_video_probe.dart'; import 'package:komet/core/media/gallery_source.dart'; -import 'package:komet/core/media/image_optimizer.dart'; import 'package:komet/core/utils/format.dart'; import 'package:komet/frontend/screens/chats/chat_info_screen.dart'; import 'package:komet/frontend/screens/contacts/open_contact_profile.dart'; @@ -345,8 +350,10 @@ class _ChatScreenState extends State formatElapsed: formatVoiceElapsed, ); + StreamSubscription? _mediaSendSub; + ValueListenable>? _photoProgressFor(CachedMessage m) => - _photoUploadProgress[m.id]; + _photoUploadProgress[m.id] ?? MediaSendService.instance.progressFor(m.id); ValueNotifier?> _reactionNotifierFor(CachedMessage m) { final existing = _reactionNotifiers[m.id]; @@ -654,6 +661,7 @@ class _ChatScreenState extends State .catchError((_) {}), ); WidgetsBinding.instance.addObserver(this); + _mediaSendSub = MediaSendService.instance.events.listen(_onMediaSendEvent); chats.chatsChanged.addListener(_onChatsBump); _messageController.addListener(_onTextChanged); _scrollController.addListener(_onScrollForDate); @@ -865,6 +873,7 @@ class _ChatScreenState extends State _hasMoreHistory = !cached.reachedStart; _messagesRev.value++; }); + _mergePendingMedia(); _syncReactionNotifiersFromMessages(); _requestCommentCounts(); _revealOrHoldInitial(); @@ -886,6 +895,7 @@ class _ChatScreenState extends State _messages = first; _messagesRev.value++; }); + _mergePendingMedia(); _requestCommentCounts(); _revealOrHoldInitial(); } @@ -1999,6 +2009,7 @@ class _ChatScreenState extends State unawaited(chats.subscribeChat(api, widget.chatId, subscribe: false)); } WidgetsBinding.instance.removeObserver(this); + _mediaSendSub?.cancel(); chats.chatsChanged.removeListener(_onChatsBump); _otherUnread.dispose(); _animojiHold.dispose(); @@ -3194,6 +3205,25 @@ class _ChatScreenState extends State ); } + bool get _hasMiniApp { + if (widget.chatType != 'DIALOG' || _commentsMode) return false; + final peerId = _resolveOtherId(); + if (peerId == null) return false; + if (hasMiniAppOption(ContactCache.getOptions(peerId))) return true; + return hasMiniAppOption(chat?.options); + } + + Future _openMiniApp() async { + final peerId = _resolveOtherId(); + if (peerId == null) return; + await openMiniApp( + context, + botId: peerId, + chatId: widget.chatId, + title: _headerName(), + ); + } + void _openChatMenu(BuildContext btnContext) { final box = btnContext.findRenderObject() as RenderBox?; if (box == null || !box.hasSize) return; @@ -3202,6 +3232,13 @@ class _ChatScreenState extends State context: context, anchorRect: anchorRect, items: [ + if (_hasMiniApp) + ChatMenuItem( + icon: Symbols.apps, + label: AppLocalizations.of(context)!.miniAppOpen, + dividerAfter: true, + onTap: () => unawaited(_openMiniApp()), + ), ChatMenuItem( icon: (chat?.isMuted ?? false) ? Symbols.volume_off @@ -6150,75 +6187,33 @@ class _ChatScreenState extends State if (jobs.isEmpty || !mounted) return; final tempId = _nextTempId(); - final now = DateTime.now().millisecondsSinceEpoch; - final progress = ValueNotifier>( - List.filled(jobs.length, 0), + final placeholder = CachedMessage( + id: tempId, + accountId: _myId, + chatId: widget.chatId, + senderId: _myId, + text: caption.isEmpty ? null : caption, + time: DateTime.now().millisecondsSinceEpoch, + status: 'sending', + attachments: attachments, ); - _photoUploadProgress[tempId] = progress; - _messages.add( - CachedMessage( - id: tempId, - accountId: _myId, - chatId: widget.chatId, - senderId: _myId, - text: caption.isEmpty ? null : caption, - time: now, - status: 'sending', - attachments: attachments, - ), - ); + _messages.add(placeholder); _lastSentId = tempId; _bumpMessages(); Haptics.send(); _scrollToBottom(); - try { - final tokens = await _uploadPhotos(jobs, progress); - if (!mounted) { - _disposePhotoProgress(tempId); - return; - } - if (tokens.any((t) => t == null)) { - _failPhotoMessage(tempId); - return; - } - - progress.value = List.filled(jobs.length, 1); - - final serverMsg = await messagesModule.sendPhotoMessage( - widget.chatId, - tokens.cast(), - caption: caption.isEmpty ? null : caption, - ); - if (!mounted) { - _disposePhotoProgress(tempId); - return; - } - if (serverMsg == null) { - _failPhotoMessage(tempId); - return; - } - - final real = CachedMessage.fromPushPayload( - _myId, - widget.chatId, - serverMsg, - ); - final idx = _messages.indexWhere((m) => m.id == tempId); - if (idx != -1) { - _messages[idx] = real; - _bumpMessages(); - unawaited(_persistOutgoing(real)); - } - _disposePhotoProgress(tempId); - } catch (e) { - if (mounted) { - _failPhotoMessage(tempId); - } else { - _disposePhotoProgress(tempId); - } - } + unawaited( + MediaSendService.instance.sendPhotos( + accountId: _myId, + chatId: widget.chatId, + tempId: tempId, + jobs: jobs, + caption: caption, + placeholder: placeholder, + ), + ); } Future _sendVideo( @@ -6233,103 +6228,127 @@ class _ChatScreenState extends State await video.item.originFile(); if (file == null || !mounted) return; - final scheduled = scheduledTime != null; - final durationMs = video.item.duration?.inMilliseconds; + var durationMs = video.item.duration?.inMilliseconds; + if (durationMs == null && DesktopVideoProbe.supported) { + durationMs = (await DesktopVideoProbe.duration(file.path))?.inMilliseconds; + } + final dims = await video.item.dimensions(); + Uint8List? thumbBytes; + try { + thumbBytes = await video.item.thumbnail(512); + } catch (_) {} + if (!mounted) return; + final thumbData = thumbBytes == null || thumbBytes.isEmpty + ? null + : 'data:image/jpeg;base64,${base64Encode(thumbBytes)}'; - String? tempId; - ValueNotifier>? progress; - if (scheduled) { + final tempId = _nextTempId(); + CachedMessage? placeholder; + + if (scheduledTime != null) { showCustomNotification(context, 'Загрузка…'); } else { - tempId = _nextTempId(); - progress = ValueNotifier>(const [0]); - _photoUploadProgress[tempId] = progress; - _messages.add( - CachedMessage( - id: tempId, - accountId: _myId, - chatId: widget.chatId, - senderId: _myId, - text: caption.isEmpty ? null : caption, - time: DateTime.now().millisecondsSinceEpoch, - status: 'sending', - attachments: [VideoAttachment(duration: durationMs)], - ), + placeholder = CachedMessage( + id: tempId, + accountId: _myId, + chatId: widget.chatId, + senderId: _myId, + text: caption.isEmpty ? null : caption, + time: DateTime.now().millisecondsSinceEpoch, + status: 'sending', + attachments: [ + VideoAttachment( + duration: durationMs, + localPath: file.path, + previewData: thumbData, + width: dims?.$1, + height: dims?.$2, + ), + ], ); + _messages.add(placeholder); _lastSentId = tempId; _bumpMessages(); Haptics.send(); _scrollToBottom(); } - final progressNotifier = progress; - try { - final info = await messagesModule.requestVideoUploadUrl(); - if (info == null || info.url.isEmpty) throw Exception('no_url'); - - final ok = await fileUploader.uploadVideoFile( - Uri.parse(info.url), - file, - onProgress: progressNotifier == null - ? null - : (sent, total) { - if (total > 0) { - progressNotifier.value = [(sent / total).clamp(0.0, 1.0)]; - } - }, - ); - if (!ok) throw Exception('upload_failed'); - if (!mounted) { - if (tempId != null) _disposePhotoProgress(tempId); - return; - } - - final serverMsg = await messagesModule.sendVideoMessage( - widget.chatId, - info.token, - caption: caption.isEmpty ? null : caption, + unawaited( + MediaSendService.instance.sendVideo( + accountId: _myId, + chatId: widget.chatId, + tempId: tempId, + file: file, + caption: caption, + placeholder: placeholder, scheduledTime: scheduledTime, - ); - if (!mounted) { - if (tempId != null) _disposePhotoProgress(tempId); - return; - } - if (serverMsg == null) throw Exception('send_failed'); + ), + ); + } - if (scheduled) { + void _onMediaSendEvent(MediaSendEvent event) { + if (!mounted || event.chatId != widget.chatId) return; + if (event is MediaSendDone) { + if (event.scheduled) { Haptics.send(); _markHasScheduled(); + final at = event.scheduledTime; showCustomNotification( context, - 'Запланировано на ' - '${formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(scheduledTime))}', + at == null + ? 'Запланировано' + : 'Запланировано на ' + '${formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(at))}', ); - } else { - final real = CachedMessage.fromPushPayload( - _myId, - widget.chatId, - serverMsg, - ); - final idx = _messages.indexWhere((m) => m.id == tempId); - if (idx != -1) { - _messages[idx] = real; - _bumpMessages(); - unawaited(_persistOutgoing(real, removeId: tempId)); - } - _disposePhotoProgress(tempId!); - } - } catch (_) { - if (!mounted) { - if (tempId != null) _disposePhotoProgress(tempId); return; } - if (scheduled) { + final real = event.message; + if (real == null) return; + final idx = _messages.indexWhere((m) => m.id == event.tempId); + if (idx != -1) { + _messages[idx] = real; + _bumpMessages(); + } + } else if (event is MediaSendFailed) { + if (event.scheduled) { Haptics.error(); - showCustomNotification(context, 'Не удалось запланировать видео'); - } else { - _failPhotoMessage(tempId!); + showCustomNotification(context, 'Не удалось запланировать'); + return; + } + _failPhotoMessage(event.tempId); + } + } + + void _mergePendingMedia() { + final service = MediaSendService.instance; + var changed = false; + + for (var i = _messages.length - 1; i >= 0; i--) { + final msg = _messages[i]; + if (!isSendingStatus(msg.status)) continue; + final done = service.completedFor(msg.id); + if (done != null) { + if (done.id != msg.id && _messages.any((m) => m.id == done.id)) { + _messages.removeAt(i); + } else { + _messages[i] = done; + } + changed = true; + continue; + } + if (service.didFail(msg.id)) { + _messages[i] = msg.copyWith(status: 'error'); + changed = true; } } + + for (final msg in service.pendingFor(widget.chatId)) { + if (_messages.any((m) => m.id == msg.id)) continue; + _messages.add(msg); + changed = true; + } + + if (changed) _bumpMessages(); } Future _sendScheduledPhotos( @@ -6360,43 +6379,16 @@ class _ChatScreenState extends State if (jobs.isEmpty || !mounted) return; showCustomNotification(context, 'Загрузка…'); - final progress = ValueNotifier>( - List.filled(jobs.length, 0), - ); - try { - final tokens = await _uploadPhotos(jobs, progress); - if (!mounted) return; - if (tokens.any((t) => t == null)) { - showCustomNotification(context, 'Не удалось загрузить фото'); - return; - } - - final result = await messagesModule.sendPhotoMessage( - widget.chatId, - tokens.cast(), - caption: caption.isEmpty ? null : caption, + unawaited( + MediaSendService.instance.sendPhotos( + accountId: _myId, + chatId: widget.chatId, + tempId: _nextTempId(), + jobs: jobs, + caption: caption, scheduledTime: scheduledTime, - ); - if (!mounted) return; - if (result != null) { - Haptics.send(); - _markHasScheduled(); - showCustomNotification( - context, - 'Запланировано на ' - '${formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(scheduledTime))}', - ); - } else { - showCustomNotification(context, 'Не удалось запланировать'); - } - } catch (_) { - if (mounted) { - Haptics.error(); - showCustomNotification(context, 'Ошибка при загрузке'); - } - } finally { - progress.dispose(); - } + ), + ); } Future _sendAttachMessage( @@ -6547,105 +6539,11 @@ class _ChatScreenState extends State ); } - static const int _photoUploadConcurrency = 3; - static const int _photoUploadAttempts = 3; - - Future> _uploadPhotos( - List<({File file, GalleryItem? item})> jobs, - ValueNotifier> progress, - ) async { - final tokens = List.filled(jobs.length, null); - var nextIndex = 0; - var failed = false; - - Future worker() async { - while (!failed) { - final i = nextIndex++; - if (i >= jobs.length) return; - final token = await _uploadOnePhoto(jobs[i], i, progress); - if (token == null) { - failed = true; - return; - } - tokens[i] = token; - } - } - - final workerCount = math.min(_photoUploadConcurrency, jobs.length); - await Future.wait(List.generate(workerCount, (_) => worker())); - return tokens; - } - - Future _uploadOnePhoto( - ({File file, GalleryItem? item}) job, - int index, - ValueNotifier> progress, - ) async { - File file; - try { - file = await optimizePhotoForUpload(job.file, item: job.item); - } catch (e) { - logger.w('optimize photo: $e'); - file = job.file; - } - for (var attempt = 0; attempt < _photoUploadAttempts; attempt++) { - if (attempt > 0) { - await Future.delayed(Duration(seconds: attempt)); - if (!mounted) return null; - _setPhotoProgress(progress, index, 0); - } - try { - final url = await messagesModule.requestPhotoUploadUrl(); - if (url == null || url.isEmpty) continue; - final token = await fileUploader.uploadPhoto( - Uri.parse(url), - file, - filename: _photoFilename(file), - onProgress: (sent, total) { - if (total <= 0) return; - _setPhotoProgress(progress, index, (sent / total).clamp(0.0, 1.0)); - }, - ); - if (token != null) return token; - } catch (e) { - logger.w('uploadOnePhoto attempt ${attempt + 1}: $e'); - } - } - return null; - } - - void _setPhotoProgress( - ValueNotifier> progress, - int index, - double value, - ) { - final next = List.from(progress.value); - if (index < next.length) { - next[index] = value; - progress.value = next; - } - } - - String _photoFilename(File file) { - final segments = file.uri.pathSegments; - final name = segments.isNotEmpty ? segments.last : ''; - return name.isNotEmpty ? name : 'photo.jpg'; - } void _failPhotoMessage(String tempId) { final idx = _messages.indexWhere((m) => m.id == tempId); if (idx != -1) { - final old = _messages[idx]; - _messages[idx] = CachedMessage( - id: old.id, - accountId: old.accountId, - chatId: old.chatId, - senderId: old.senderId, - text: old.text, - time: old.time, - status: 'error', - attachments: old.attachments, - ); + _messages[idx] = _messages[idx].copyWith(status: 'error'); _bumpMessages(); } _disposePhotoProgress(tempId); @@ -6748,6 +6646,11 @@ class _ChatScreenState extends State : _addOptimisticFileMessage( FileAttachment(name: file.name, size: file.size), ); + ValueNotifier>? fileProgress; + if (tempId != null) { + fileProgress = ValueNotifier>(const [0]); + _photoUploadProgress[tempId] = fileProgress; + } UploadNotificationService.start(file.name); @@ -6777,6 +6680,9 @@ class _ChatScreenState extends State sent: sent, total: total, ); + if (total > 0) { + fileProgress?.value = [(sent / total).clamp(0.0, 1.0)]; + } final nowMs = DateTime.now().millisecondsSinceEpoch; final elapsed = nowMs - notifLastMs; if (elapsed >= 500) { @@ -6819,8 +6725,9 @@ class _ChatScreenState extends State '${formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(scheduledTime))}', ); } else { + _disposePhotoProgress(tempId!); _updateFileMessageStatus( - tempId!, + tempId, 'sent', realId: messageId, attachment: FileAttachment( @@ -6834,7 +6741,10 @@ class _ChatScreenState extends State case UploadError(:final message): stopNotif(); showCustomNotification(context, 'Ошибка: $message'); - if (tempId != null) _updateFileMessageStatus(tempId, 'error'); + if (tempId != null) { + _disposePhotoProgress(tempId); + _updateFileMessageStatus(tempId, 'error'); + } } }, onDone: () { @@ -6852,6 +6762,7 @@ class _ChatScreenState extends State ), ); if (inFlight.id == tempId && inFlight.status == 'sending') { + _disposePhotoProgress(tempId); _updateFileMessageStatus(tempId, 'error'); } } @@ -6863,7 +6774,10 @@ class _ChatScreenState extends State if (!mounted) return; stopNotif(); showCustomNotification(context, 'Ошибка: $e'); - if (tempId != null) _updateFileMessageStatus(tempId, 'error'); + if (tempId != null) { + _disposePhotoProgress(tempId); + _updateFileMessageStatus(tempId, 'error'); + } _uploadStatus.value = const UploadStatus(); _uploadSub = null; if (!done.isCompleted) done.complete(); diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 3982312..44ebb30 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -4,8 +4,6 @@ import 'dart:ui' show lerpDouble; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart' - show OverScrollHeaderStretchConfiguration; import 'package:flutter/services.dart' show HapticFeedback; import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -22,6 +20,7 @@ import '../../widgets/avatar_history_screen.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/info_action_sheet.dart'; import '../../widgets/komet_avatar.dart'; +import '../../widgets/profile_header_scroll.dart'; import '../../widgets/settings_card.dart'; import '../../widgets/sheet_helpers.dart'; import '../../widgets/small_spinner.dart'; @@ -367,14 +366,14 @@ class _SettingsTabState extends State with SpectrumSurface { controller: _scrollController ??= ScrollController( initialScrollOffset: delta, ), - physics: _HeaderPullScrollPhysics( + physics: HeaderPullScrollPhysics( delta: delta, isArmed: () => _expandArmed, parent: const BouncingScrollPhysics(), ), slivers: [ SliverPersistentHeader( - delegate: _ProfileHeaderDelegate( + delegate: MorphHeaderDelegate( collapsedExtent: collapsedH, expandedExtent: expandedH, headerBuilder: (ctx, t) => @@ -1060,125 +1059,6 @@ class _SettingsTabState extends State with SpectrumSurface { } } -class _HeaderPullScrollPhysics extends ScrollPhysics { - final double delta; - final ValueGetter isArmed; - - const _HeaderPullScrollPhysics({ - required this.delta, - required this.isArmed, - super.parent, - }); - - static final SpringDescription _expressiveSpring = - SpringDescription.withDampingRatio(mass: 1, stiffness: 380, ratio: 0.9); - - static const double _flingVelocity = 400; - - @override - _HeaderPullScrollPhysics applyTo(ScrollPhysics? ancestor) { - return _HeaderPullScrollPhysics( - delta: delta, - isArmed: isArmed, - parent: buildParent(ancestor), - ); - } - - @override - double applyPhysicsToUserOffset(ScrollMetrics position, double offset) { - if (delta <= 0 || offset <= 0 || position.pixels <= 0) { - return super.applyPhysicsToUserOffset(position, offset); - } - final px = position.pixels; - final free = math.max(0.0, px - delta); - if (offset <= free) return offset; - if (!isArmed()) return free; - final inZone = offset - free; - final expandedFraction = (1 - math.min(px, delta) / delta).clamp(0.0, 1.0); - final friction = lerpDouble(0.58, 0.3, expandedFraction)!; - return free + inZone * friction; - } - - @override - Simulation? createBallisticSimulation( - ScrollMetrics position, - double velocity, - ) { - if (delta > 0) { - final px = position.pixels; - final tolerance = toleranceFor(position); - if (px > 0 && px < delta) { - final double target; - if (velocity <= -_flingVelocity) { - target = 0; - } else if (velocity >= _flingVelocity) { - target = delta; - } else { - target = px < delta / 2 ? 0 : delta; - } - if ((target - px).abs() < tolerance.distance && - velocity.abs() < tolerance.velocity) { - return null; - } - return ScrollSpringSimulation( - _expressiveSpring, - px, - target, - velocity, - tolerance: tolerance, - ); - } - if (px >= delta && velocity < 0) { - return BouncingScrollSimulation( - position: px, - velocity: velocity, - leadingExtent: delta, - trailingExtent: math.max(delta, position.maxScrollExtent), - spring: spring, - tolerance: tolerance, - ); - } - } - return super.createBallisticSimulation(position, velocity); - } -} - -class _ProfileHeaderDelegate extends SliverPersistentHeaderDelegate { - final double collapsedExtent; - final double expandedExtent; - final Widget Function(BuildContext context, double t) headerBuilder; - - _ProfileHeaderDelegate({ - required this.collapsedExtent, - required this.expandedExtent, - required this.headerBuilder, - }); - - @override - double get minExtent => collapsedExtent; - - @override - double get maxExtent => expandedExtent; - - @override - OverScrollHeaderStretchConfiguration get stretchConfiguration => - OverScrollHeaderStretchConfiguration(); - - @override - Widget build( - BuildContext context, - double shrinkOffset, - bool overlapsContent, - ) { - final range = expandedExtent - collapsedExtent; - final t = range <= 0 ? 0.0 : (1 - shrinkOffset / range).clamp(0.0, 1.0); - return headerBuilder(context, t); - } - - @override - bool shouldRebuild(covariant _ProfileHeaderDelegate oldDelegate) => true; -} - class _SettingsItem { final IconData? icon; final Widget? leading; diff --git a/lib/frontend/screens/stories/story_peanut.dart b/lib/frontend/screens/stories/story_peanut.dart new file mode 100644 index 0000000..4ec1591 --- /dev/null +++ b/lib/frontend/screens/stories/story_peanut.dart @@ -0,0 +1,220 @@ +import 'dart:async'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +import '../../../core/media/preview_image.dart'; +import '../../../models/story.dart'; + +ImageProvider? storyThumbProvider(Story story) { + final media = story.media; + if (media == null) return null; + final url = media.isVideo ? (media.thumbnailUrl ?? media.url) : media.url; + if (url != null && url.isNotEmpty) { + return CachedNetworkImageProvider(url, maxWidth: 160, maxHeight: 160); + } + return dataUriImage(story, media.previewData); +} + +class StoryPeanut extends StatefulWidget { + final List stories; + final double diameter; + final double strokeWidth; + final double gap; + final Color outlineColor; + final Duration cycle; + + const StoryPeanut({ + super.key, + required this.stories, + this.diameter = 30, + this.strokeWidth = 1.8, + this.gap = 1.6, + this.outlineColor = Colors.white, + this.cycle = const Duration(seconds: 3), + }); + + static const int maxCircles = 3; + + @override + State createState() => _StoryPeanutState(); +} + +class _StoryPeanutState extends State { + Timer? _timer; + int _cycleIndex = 0; + + bool get _cycling => widget.stories.length > StoryPeanut.maxCircles; + + int get _circleCount => + _cycling ? 1 : widget.stories.length.clamp(0, StoryPeanut.maxCircles); + + @override + void initState() { + super.initState(); + _syncTimer(); + } + + @override + void didUpdateWidget(StoryPeanut old) { + super.didUpdateWidget(old); + if (old.stories.length != widget.stories.length || + old.cycle != widget.cycle) { + _cycleIndex = 0; + _syncTimer(); + } + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + void _syncTimer() { + _timer?.cancel(); + if (!_cycling) return; + _timer = Timer.periodic(widget.cycle, (_) { + if (!mounted) return; + setState(() { + _cycleIndex = (_cycleIndex + 1) % widget.stories.length; + }); + }); + } + + @override + Widget build(BuildContext context) { + final count = _circleCount; + if (count == 0) return const SizedBox.shrink(); + + final d = widget.diameter; + final step = d * 0.68; + final pad = widget.strokeWidth + widget.gap; + final width = d + step * (count - 1); + + return SizedBox( + width: width, + height: d, + child: Stack( + clipBehavior: Clip.none, + children: [ + for (var i = 0; i < count; i++) + Positioned( + left: step * i, + top: 0, + width: d, + height: d, + child: ClipPath( + clipper: i == count - 1 + ? null + : _NotchClipper( + center: Offset(step + d / 2, d / 2), + radius: d / 2 + widget.gap, + ), + child: Padding( + padding: EdgeInsets.all(pad), + child: ClipOval(child: _thumb(i)), + ), + ), + ), + Positioned.fill( + child: IgnorePointer( + child: CustomPaint( + painter: _PeanutOutlinePainter( + count: count, + diameter: d, + step: step, + strokeWidth: widget.strokeWidth, + color: widget.outlineColor, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _thumb(int index) { + final story = _cycling + ? widget.stories[_cycleIndex % widget.stories.length] + : widget.stories[index]; + final provider = storyThumbProvider(story); + final fill = ColoredBox(color: Colors.white.withValues(alpha: 0.22)); + final image = provider == null + ? fill + : Image(image: provider, fit: BoxFit.cover, gaplessPlayback: true); + if (!_cycling) return image; + return AnimatedSwitcher( + duration: const Duration(milliseconds: 320), + child: KeyedSubtree(key: ValueKey(story.id), child: image), + ); + } +} + +class _NotchClipper extends CustomClipper { + final Offset center; + final double radius; + + const _NotchClipper({required this.center, required this.radius}); + + @override + Path getClip(Size size) { + return Path.combine( + PathOperation.difference, + Path()..addOval(Offset.zero & size), + Path()..addOval(Rect.fromCircle(center: center, radius: radius)), + ); + } + + @override + bool shouldReclip(_NotchClipper old) => + old.center != center || old.radius != radius; +} + +class _PeanutOutlinePainter extends CustomPainter { + final int count; + final double diameter; + final double step; + final double strokeWidth; + final Color color; + + const _PeanutOutlinePainter({ + required this.count, + required this.diameter, + required this.step, + required this.strokeWidth, + required this.color, + }); + + @override + void paint(Canvas canvas, Size size) { + final radius = diameter / 2 - strokeWidth / 2; + Path union = Path(); + for (var i = 0; i < count; i++) { + final circle = Path() + ..addOval( + Rect.fromCircle( + center: Offset(diameter / 2 + step * i, diameter / 2), + radius: radius, + ), + ); + union = i == 0 ? circle : Path.combine(PathOperation.union, union, circle); + } + canvas.drawPath( + union, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..color = color, + ); + } + + @override + bool shouldRepaint(_PeanutOutlinePainter old) => + old.count != count || + old.diameter != diameter || + old.step != step || + old.strokeWidth != strokeWidth || + old.color != color; +} diff --git a/lib/frontend/screens/stories/story_ring.dart b/lib/frontend/screens/stories/story_ring.dart index 3315c1a..9793e1e 100644 --- a/lib/frontend/screens/stories/story_ring.dart +++ b/lib/frontend/screens/stories/story_ring.dart @@ -8,6 +8,64 @@ import '../../../models/story.dart'; import '../../widgets/komet_avatar.dart'; import 'story_owner_info.dart'; +class StoryAvatarRing extends StatelessWidget { + final double diameter; + final int total; + final int read; + final double strokeWidth; + final double ringGap; + final double haloWidth; + final Widget child; + + const StoryAvatarRing({ + super.key, + required this.diameter, + required this.child, + this.total = 0, + this.read = 0, + this.strokeWidth = 2.8, + this.ringGap = 6, + this.haloWidth = 2, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final outer = diameter + ringGap * 2; + final visible = total > 0; + return SizedBox( + width: outer, + height: outer, + child: Stack( + alignment: Alignment.center, + children: [ + CustomPaint( + size: Size.square(outer), + painter: visible + ? SegmentedRingPainter( + total: total, + read: read, + unreadColors: [cs.primary, cs.tertiary, cs.primary], + readColor: cs.outlineVariant, + strokeWidth: strokeWidth, + ) + : null, + ), + Container( + width: diameter + haloWidth * 2, + height: diameter + haloWidth * 2, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: visible ? cs.surface : Colors.transparent, + ), + ), + child, + ], + ), + ); + } +} + /// Кольцо-превью истории владельца в шапке списка чатов. class StoryRing extends StatefulWidget { final StoryPreview preview; @@ -71,36 +129,14 @@ class _StoryRingState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - SizedBox( - width: diameter + 12, - height: diameter + 12, - child: Stack( - alignment: Alignment.center, - children: [ - CustomPaint( - size: Size.square(diameter + 12), - painter: _SegmentedRingPainter( - total: widget.preview.totalCount, - read: widget.preview.readCount, - unreadColors: [cs.primary, cs.tertiary, cs.primary], - readColor: cs.outlineVariant, - strokeWidth: 2.8, - ), - ), - Container( - width: diameter + 4, - height: diameter + 4, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: cs.surface, - ), - ), - KometAvatar( - name: name == '…' ? '?' : name, - size: diameter, - imageUrl: info?.avatarUrl, - ), - ], + StoryAvatarRing( + diameter: diameter, + total: widget.preview.totalCount, + read: widget.preview.readCount, + child: KometAvatar( + name: name == '…' ? '?' : name, + size: diameter, + imageUrl: info?.avatarUrl, ), ), const SizedBox(height: 6), @@ -128,14 +164,14 @@ class _StoryRingState extends State { } /// Прерывистое кольцо: одна дуга на каждую историю; прочитанные приглушены. -class _SegmentedRingPainter extends CustomPainter { +class SegmentedRingPainter extends CustomPainter { final int total; final int read; final List unreadColors; final Color readColor; final double strokeWidth; - _SegmentedRingPainter({ + SegmentedRingPainter({ required this.total, required this.read, required this.unreadColors, @@ -146,9 +182,12 @@ class _SegmentedRingPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final n = total < 1 ? 1 : total; - final center = size.center(Offset.zero); - final radius = (size.width - strokeWidth) / 2; - final rect = Rect.fromCircle(center: center, radius: radius); + final rect = Rect.fromLTWH( + strokeWidth / 2, + strokeWidth / 2, + size.width - strokeWidth, + size.height - strokeWidth, + ); final segment = (2 * math.pi) / n; final gap = n == 1 ? 0.0 : math.min(0.16, segment * 0.30); @@ -178,7 +217,7 @@ class _SegmentedRingPainter extends CustomPainter { } @override - bool shouldRepaint(_SegmentedRingPainter old) => + bool shouldRepaint(SegmentedRingPainter old) => old.total != total || old.read != read || old.readColor != readColor || @@ -260,7 +299,7 @@ class _StorySelfTileState extends State { if (preview != null) CustomPaint( size: Size.square(diameter + 12), - painter: _SegmentedRingPainter( + painter: SegmentedRingPainter( total: preview.totalCount, read: preview.readCount, unreadColors: [cs.primary, cs.tertiary, cs.primary], diff --git a/lib/frontend/screens/stories/story_viewer_screen.dart b/lib/frontend/screens/stories/story_viewer_screen.dart index 18f2aad..b021bd8 100644 --- a/lib/frontend/screens/stories/story_viewer_screen.dart +++ b/lib/frontend/screens/stories/story_viewer_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:math' as math; import 'dart:ui' as ui; @@ -9,28 +10,46 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:video_player/video_player.dart'; import '../../../core/utils/haptics.dart'; -import '../../../main.dart' show storiesModule; +import '../../../main.dart' show animojiModule, storiesModule; import '../../../models/story.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/komet_avatar.dart'; import '../../widgets/liquid_glass.dart'; +import '../../widgets/lottie_image.dart'; import '../../widgets/small_spinner.dart'; import 'story_owner_info.dart'; const _quickReactions = ['❤️', '🔥', '😍', '👏', '😂', '😮']; const Duration _photoDuration = Duration(seconds: 5); +class _ReactionItem { + final String emoji; + final String? lottieUrl; + final String? iconUrl; + + const _ReactionItem(this.emoji, {this.lottieUrl, this.iconUrl}); + + bool get animated => + (lottieUrl?.isNotEmpty ?? false) || (iconUrl?.isNotEmpty ?? false); +} + +Offset? storyOriginOf(BuildContext context) { + final box = context.findRenderObject() as RenderBox?; + if (box == null || !box.hasSize) return null; + return box.localToGlobal(box.size.center(Offset.zero)); +} + /// Открывает вьюер историй. Если задан [origin] (глобальный центр нажатого /// кольца) — открытие анимируется расширяющимся из этой точки кругом; иначе — /// масштабным «зумом». -void openStoryViewer( +Future openStoryViewer( BuildContext context, { required List previews, int initialIndex = 0, Map ownerOverrides = const {}, Offset? origin, }) { - Navigator.of(context).push( + return Navigator.of(context).push( PageRouteBuilder( opaque: false, transitionDuration: const Duration(milliseconds: 420), @@ -127,6 +146,10 @@ class _StoryViewerScreenState extends State final List<_Burst> _bursts = []; int _burstSeq = 0; + List<_ReactionItem> _reactions = _quickReactions + .map((e) => _ReactionItem(e)) + .toList(); + VideoPlayerController? _video; StoryPreview get _owner => widget.previews[_ownerIndex]; @@ -150,6 +173,23 @@ class _StoryViewerScreenState extends State if (s == AnimationStatus.completed) _advance(); }); _loadOwner(_ownerIndex, autostart: true); + unawaited(_loadReactions()); + } + + Future _loadReactions() async { + try { + await animojiModule.ensureLoaded(); + } catch (_) { + return; + } + final quick = animojiModule.quickAnimojis; + if (!mounted || quick.isEmpty) return; + setState(() { + _reactions = [ + for (final a in quick) + _ReactionItem(a.emoji, lottieUrl: a.lottieUrl, iconUrl: a.iconUrl), + ]; + }); } @override @@ -185,19 +225,16 @@ class _StoryViewerScreenState extends State } } - /// Индекс, с которого начать показ: сначала — сохранённая позиция просмотра, - /// иначе — первая непрочитанная. int _resumeIndex(int index, List stories) { if (stories.isEmpty) return 0; - final ownerId = widget.previews[index].owner.ownerId; - final savedId = storiesModule.lastViewedStoryId(ownerId); - if (savedId != null) { - final i = stories.indexWhere((s) => s.id == savedId); - if (i >= 0) return i; - } - final read = widget.previews[index].readCount; - if (read > 0 && read < stories.length) return read; - return 0; + final preview = widget.previews[index]; + final read = preview.readCount; + final firstUnread = (read > 0 && read < stories.length) ? read : 0; + final savedId = storiesModule.lastViewedStoryId(preview.owner.ownerId); + if (savedId == null) return firstUnread; + final saved = stories.indexWhere((s) => s.id == savedId); + if (saved <= firstUnread || saved >= stories.length - 1) return firstUnread; + return saved; } void _startStory(int index) { @@ -262,6 +299,7 @@ class _StoryViewerScreenState extends State if (_storyIndex + 1 < _ownerStories.length) { _startStory(_storyIndex + 1); } else { + storiesModule.clearLastViewed(_owner.owner.ownerId); _nextOwner(); } } @@ -317,9 +355,9 @@ class _StoryViewerScreenState extends State } } - void _spawnBurst(String emoji, Alignment from) { + void _spawnBurst(_ReactionItem item, Alignment from) { final id = _burstSeq++; - setState(() => _bursts.add(_Burst(id, emoji, from))); + setState(() => _bursts.add(_Burst(id, item, from))); } void _removeBurst(int id) { @@ -327,20 +365,22 @@ class _StoryViewerScreenState extends State setState(() => _bursts.removeWhere((b) => b.id == id)); } - Future _toggleReaction(String emoji) async { + Future _toggleReaction(_ReactionItem item) async { final story = _currentStory; if (story == null || story.id == 0) return; - final isSame = story.reaction?.id == emoji; + final isSame = story.reaction?.id == item.emoji; if (!isSame) { Haptics.medium(); - _spawnBurst(emoji, const Alignment(0, 0.55)); + _spawnBurst(item, const Alignment(0, 0.55)); + final animoji = animojiModule.findByEmoji(item.emoji); + if (animoji != null) unawaited(animojiModule.noteUsed(animoji)); } else { Haptics.tap(); } final ok = await storiesModule.react( story.owner, story.id, - isSame ? null : StoryReaction(id: emoji), + isSame ? null : StoryReaction(id: item.emoji), ); if (!mounted) return; if (ok) { @@ -434,7 +474,7 @@ class _StoryViewerScreenState extends State for (final burst in _bursts) _FloatingReaction( key: ValueKey(burst.id), - emoji: burst.emoji, + item: burst.item, alignment: burst.from, onDone: () => _removeBurst(burst.id), ), @@ -635,11 +675,11 @@ class _StoryViewerScreenState extends State child: Row( mainAxisSize: MainAxisSize.min, children: [ - for (final emoji in _quickReactions) + for (final item in _reactions) _ReactionButton( - emoji: emoji, - selected: current == emoji, - onTap: () => _toggleReaction(emoji), + item: item, + selected: current == item.emoji, + onTap: () => _toggleReaction(item), ), ], ), @@ -754,12 +794,12 @@ class _SegmentBar extends StatelessWidget { // ─── Reaction emoji button ──────────────────────────────────────────────── class _ReactionButton extends StatefulWidget { - final String emoji; + final _ReactionItem item; final bool selected; final VoidCallback onTap; const _ReactionButton({ - required this.emoji, + required this.item, required this.selected, required this.onTap, }); @@ -803,7 +843,18 @@ class _ReactionButtonState extends State<_ReactionButton> padding: const EdgeInsets.symmetric(horizontal: 6), child: Transform.scale( scale: scale, - child: Text(widget.emoji, style: const TextStyle(fontSize: 28)), + child: widget.item.animated + ? LottieImage( + lottieUrl: widget.item.lottieUrl, + url: widget.item.iconUrl, + size: 30, + shimmer: false, + memCacheWidth: 90, + ) + : Text( + widget.item.emoji, + style: const TextStyle(fontSize: 28), + ), ), ); }, @@ -867,19 +918,19 @@ class _TopScrim extends StatelessWidget { // ─── Floating reaction burst ────────────────────────────────────────────── class _Burst { final int id; - final String emoji; + final _ReactionItem item; final Alignment from; - const _Burst(this.id, this.emoji, this.from); + const _Burst(this.id, this.item, this.from); } class _FloatingReaction extends StatefulWidget { - final String emoji; + final _ReactionItem item; final Alignment alignment; final VoidCallback onDone; const _FloatingReaction({ super.key, - required this.emoji, + required this.item, required this.alignment, required this.onDone, }); @@ -894,7 +945,7 @@ class _FloatingReactionState extends State<_FloatingReaction> vsync: this, duration: const Duration(milliseconds: 900), ); - late final double _drift = (widget.emoji.hashCode % 40 - 20).toDouble(); + late final double _drift = (widget.item.emoji.hashCode % 40 - 20).toDouble(); @override void initState() { @@ -928,10 +979,19 @@ class _FloatingReactionState extends State<_FloatingReaction> opacity: opacity.clamp(0.0, 1.0), child: Transform.scale( scale: scale, - child: Text( - widget.emoji, - style: const TextStyle(fontSize: 64), - ), + child: widget.item.animated + ? LottieImage( + lottieUrl: widget.item.lottieUrl, + url: widget.item.iconUrl, + size: 64, + shimmer: false, + repeat: false, + memCacheWidth: 192, + ) + : Text( + widget.item.emoji, + style: const TextStyle(fontSize: 64), + ), ), ), ), diff --git a/lib/frontend/screens/webapp/open_mini_app.dart b/lib/frontend/screens/webapp/open_mini_app.dart new file mode 100644 index 0000000..19c50e4 --- /dev/null +++ b/lib/frontend/screens/webapp/open_mini_app.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +import '../../../backend/modules/webapp.dart'; +import '../../../core/utils/haptics.dart'; +import '../../../core/utils/link_opener.dart'; +import '../../../core/utils/webview_support.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../../main.dart' show webAppModule; +import '../../widgets/custom_notification.dart'; +import 'web_app_screen.dart'; + +Future openMiniApp( + BuildContext context, { + required int botId, + required String title, + int? chatId, +}) async { + if (botId <= 0) return; + Haptics.tap(); + + if (webViewSupported) { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => WebAppScreen( + title: title, + loader: () => webAppModule.fetchLaunch(botId, chatId: chatId), + ), + ), + ); + return; + } + + try { + final launch = await webAppModule.fetchLaunch(botId, chatId: chatId); + if (!context.mounted) return; + await openExternalUrl(context, launch.url); + } on WebAppUnavailable catch (e) { + if (context.mounted) showCustomNotification(context, e.message); + } catch (_) { + if (context.mounted) { + showCustomNotification( + context, + AppLocalizations.of(context)!.miniAppFailed, + ); + } + } +} diff --git a/lib/frontend/widgets/attachment/attachment_sheet.dart b/lib/frontend/widgets/attachment/attachment_sheet.dart index 65a76e9..d079718 100644 --- a/lib/frontend/widgets/attachment/attachment_sheet.dart +++ b/lib/frontend/widgets/attachment/attachment_sheet.dart @@ -160,6 +160,10 @@ class _AttachmentSheetState extends State { _thumbKeys.putIfAbsent(id, () => GlobalKey<_ThumbnailState>()); void _openPreview(GalleryItem item) { + if (item.isVideo) { + _toggleSelection(item); + return; + } final thumbKey = _thumbKey(item.id); final hero = PhotoHeroController( origin: () => photoHeroRect(thumbKey), @@ -1089,7 +1093,7 @@ class _ThumbnailState extends State<_Thumbnail> { } void _resolveProvider() { - final file = widget.editedFile ?? widget.item.localFile; + final file = widget.editedFile ?? (widget.item.isVideo ? null : widget.item.localFile); if (file != null) { _provider = ResizeImage( FileImage(file), @@ -1102,7 +1106,7 @@ class _ThumbnailState extends State<_Thumbnail> { final id = widget.item.id; widget.item.thumbnail(_pixelSize).then((data) { if (!mounted || data == null || widget.item.id != id) return; - if (widget.editedFile != null || widget.item.localFile != null) return; + if (widget.editedFile != null) return; setState(() => _provider = MemoryImage(data)); }); } @@ -1119,5 +1123,16 @@ class _ThumbnailState extends State<_Thumbnail> { ); } - Widget _placeholder() => ColoredBox(color: widget.cs.surfaceContainerHighest); + Widget _placeholder() => ColoredBox( + color: widget.cs.surfaceContainerHighest, + child: widget.item.isVideo + ? Center( + child: Icon( + Symbols.movie, + size: 28, + color: widget.cs.onSurfaceVariant, + ), + ) + : null, + ); } diff --git a/lib/frontend/widgets/attachment/bubbles/bubble_context.dart b/lib/frontend/widgets/attachment/bubbles/bubble_context.dart index 34b410a..d2a6d16 100644 --- a/lib/frontend/widgets/attachment/bubbles/bubble_context.dart +++ b/lib/frontend/widgets/attachment/bubbles/bubble_context.dart @@ -8,6 +8,7 @@ import '../../../../core/config/komet_settings.dart'; import '../../../../core/utils/format.dart'; import '../../../../models/attachment.dart'; import '../../formatted_message_text.dart'; +import '../../sending_clock_icon.dart'; import '../../photo_viewer.dart'; enum MessageType { text, attachment, voice, control } @@ -202,6 +203,9 @@ class BubbleContext { Widget _statusIconFor(String? status, {Color? color, double size = 14}) { final v = messageStatusVisual(status, dimColor: color ?? dim); + if (isSendingStatus(status)) { + return SendingClockIcon(color: v.color, size: size); + } return Icon(v.icon, size: size, color: v.color); } } diff --git a/lib/frontend/widgets/attachment/bubbles/file_bubble.dart b/lib/frontend/widgets/attachment/bubbles/file_bubble.dart index 965df51..bb4ecf3 100644 --- a/lib/frontend/widgets/attachment/bubbles/file_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/file_bubble.dart @@ -17,6 +17,7 @@ import '../../../../models/attachment.dart'; import '../../custom_notification.dart'; import '../../decrypted_photo.dart'; import '../../photo_viewer.dart'; +import '../../upload_progress_ring.dart'; import 'bubble_context.dart'; class FileBubble extends StatelessWidget { @@ -69,11 +70,24 @@ class FileBubble extends StatelessWidget { color: isMe ? ctx.systemTint : ctx.cs.primaryContainer, borderRadius: BorderRadius.circular(10), ), - child: Icon( - Symbols.description, - color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, - size: 20, - ), + child: ctx.uploadProgress == null + ? Icon( + Symbols.description, + color: isMe + ? ctx.cs.onPrimaryContainer + : ctx.cs.primary, + size: 20, + ) + : UploadProgressRing( + progress: ctx.uploadProgress!, + color: isMe + ? ctx.cs.onPrimaryContainer + : ctx.cs.primary, + size: 38, + strokeWidth: 2.4, + iconSize: 14, + padding: const EdgeInsets.all(4), + ), ), const SizedBox(width: 10), Flexible( diff --git a/lib/frontend/widgets/attachment/bubbles/sticker_bubble.dart b/lib/frontend/widgets/attachment/bubbles/sticker_bubble.dart index 2c15657..6eb68d8 100644 --- a/lib/frontend/widgets/attachment/bubbles/sticker_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/sticker_bubble.dart @@ -3,6 +3,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../../models/attachment.dart'; import '../../lottie_image.dart'; +import '../../sending_clock_icon.dart'; import 'bubble_context.dart'; class StickerBubble extends StatelessWidget { @@ -86,6 +87,9 @@ class StickerBubble extends StatelessWidget { Widget _buildStickerStatusIcon() { final status = ctx.overrideStatus ?? ctx.message.status; final v = messageStatusVisual(status, dimColor: Colors.white); + if (isSendingStatus(status)) { + return SendingClockIcon(color: v.color, size: 13); + } return Icon(v.icon, size: 13, color: v.color); } } diff --git a/lib/frontend/widgets/attachment/bubbles/video_bubble.dart b/lib/frontend/widgets/attachment/bubbles/video_bubble.dart index f0f0b7b..852bb48 100644 --- a/lib/frontend/widgets/attachment/bubbles/video_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/video_bubble.dart @@ -3,10 +3,12 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:komet/main.dart'; +import '../../../../core/media/preview_image.dart'; import '../../../../core/utils/format.dart'; import '../../../../core/utils/haptics.dart'; import '../../../../models/attachment.dart'; import '../../custom_notification.dart'; +import '../../upload_progress_ring.dart'; import '../../photo_viewer.dart'; import 'bubble_context.dart'; import 'video_note_bubble.dart'; @@ -63,39 +65,78 @@ class VideoBubble extends StatelessWidget { child: Icon(Symbols.videocam, size: 48, color: ctx.cs.onSurfaceVariant), ); + final localThumb = dataUriImage(video, video.previewData); + final uploading = ctx.uploadProgress; + + Widget previewImage() { + if (previewUrl.isNotEmpty && !previewUrl.startsWith('data:')) { + return CachedNetworkImage( + imageUrl: previewUrl, + width: width, + height: height, + fit: BoxFit.cover, + memCacheWidth: (width * dpr).round(), + fadeInDuration: Duration.zero, + placeholderFadeInDuration: Duration.zero, + errorWidget: (_, _, _) => localThumb == null + ? placeholder() + : Image( + image: localThumb, + width: width, + height: height, + fit: BoxFit.cover, + ), + ); + } + if (localThumb != null) { + return Image( + image: localThumb, + width: width, + height: height, + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (_, _, _) => placeholder(), + ); + } + return placeholder(); + } + final preview = ClipRRect( borderRadius: BorderRadius.circular(BubbleContext.photoBorderRadius), child: Stack( children: [ - previewUrl.isEmpty - ? placeholder() - : CachedNetworkImage( - imageUrl: previewUrl, - width: width, - height: height, - fit: BoxFit.cover, - memCacheWidth: (width * dpr).round(), - fadeInDuration: Duration.zero, - placeholderFadeInDuration: Duration.zero, - errorWidget: (_, _, _) => placeholder(), + previewImage(), + if (uploading != null) + Positioned.fill( + child: ColoredBox( + color: Colors.black.withValues(alpha: 0.35), + child: Center( + child: UploadProgressRing( + progress: uploading, + color: Colors.white, + trackColor: Colors.white24, + ), ), - Positioned.fill( - child: Center( - child: Container( - width: 48, - height: 48, - decoration: const BoxDecoration( - color: Colors.black54, - shape: BoxShape.circle, - ), - child: const Icon( - Symbols.play_arrow, - color: Colors.white, - size: 30, + ), + ) + else + Positioned.fill( + child: Center( + child: Container( + width: 48, + height: 48, + decoration: const BoxDecoration( + color: Colors.black54, + shape: BoxShape.circle, + ), + child: const Icon( + Symbols.play_arrow, + color: Colors.white, + size: 30, + ), ), ), ), - ), if (durationMs != null && durationMs > 0) Positioned( left: 6, @@ -112,12 +153,13 @@ class VideoBubble extends StatelessWidget { ), ), ), - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => _playVideo(ctx.context, video), + if (uploading == null) + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _playVideo(ctx.context, video), + ), ), - ), ], ), ); diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index f0a9c84..e97ed37 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -21,6 +21,7 @@ import '../../core/utils/webview_support.dart'; import '../../core/config/app_link_preview.dart'; import 'custom_notification.dart'; import 'formatted_message_text.dart'; +import 'sending_clock_icon.dart'; import 'photo_viewer.dart'; import 'selectable_message_text.dart'; import '../../models/attachment.dart'; @@ -1371,7 +1372,10 @@ class MessageBubble extends StatelessWidget { ), if (ctx.isMe) ...[ const SizedBox(width: 3), - Icon(statusVisual.icon, size: 13, color: statusVisual.color), + if (isSendingStatus(status)) + SendingClockIcon(color: statusVisual.color, size: 13) + else + Icon(statusVisual.icon, size: 13, color: statusVisual.color), ], if (ctx.message.deleted) ...[ const SizedBox(width: 3), @@ -1677,6 +1681,27 @@ class MessageBubble extends StatelessWidget { final rawPreview = reply.previewText(); final quotedId = reply.messageId; + if (reply.missing) { + return Container( + padding: const EdgeInsets.fromLTRB(8, 3, 8, 3), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: accent.withValues(alpha: 0.10), + border: Border(left: BorderSide(color: accent, width: 3)), + ), + child: Text( + 'сообщение удалено', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: textColor.withValues(alpha: 0.7), + fontSize: 13, + fontStyle: FontStyle.italic, + ), + ), + ); + } + final quote = Container( padding: const EdgeInsets.fromLTRB(8, 3, 8, 3), decoration: BoxDecoration( diff --git a/lib/frontend/widgets/profile_header_scroll.dart b/lib/frontend/widgets/profile_header_scroll.dart new file mode 100644 index 0000000..b4a8b2e --- /dev/null +++ b/lib/frontend/widgets/profile_header_scroll.dart @@ -0,0 +1,126 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' + show OverScrollHeaderStretchConfiguration; + +class HeaderPullScrollPhysics extends ScrollPhysics { + final double delta; + final ValueGetter isArmed; + + const HeaderPullScrollPhysics({ + required this.delta, + required this.isArmed, + super.parent, + }); + + static final SpringDescription _expressiveSpring = + SpringDescription.withDampingRatio(mass: 1, stiffness: 380, ratio: 0.9); + + static const double _flingVelocity = 400; + + @override + HeaderPullScrollPhysics applyTo(ScrollPhysics? ancestor) { + return HeaderPullScrollPhysics( + delta: delta, + isArmed: isArmed, + parent: buildParent(ancestor), + ); + } + + @override + double applyPhysicsToUserOffset(ScrollMetrics position, double offset) { + if (delta <= 0 || offset <= 0 || position.pixels <= 0) { + return super.applyPhysicsToUserOffset(position, offset); + } + final px = position.pixels; + final free = math.max(0.0, px - delta); + if (offset <= free) return offset; + if (!isArmed()) return free; + final inZone = offset - free; + final expandedFraction = (1 - math.min(px, delta) / delta).clamp(0.0, 1.0); + final friction = lerpDouble(0.58, 0.3, expandedFraction)!; + return free + inZone * friction; + } + + @override + Simulation? createBallisticSimulation( + ScrollMetrics position, + double velocity, + ) { + if (delta > 0) { + final px = position.pixels; + final tolerance = toleranceFor(position); + if (px > 0 && px < delta) { + final collapsed = math.min(delta, position.maxScrollExtent); + final double target; + if (velocity <= -_flingVelocity) { + target = 0; + } else if (velocity >= _flingVelocity) { + target = collapsed; + } else { + target = px < delta / 2 ? 0 : collapsed; + } + if ((target - px).abs() < tolerance.distance && + velocity.abs() < tolerance.velocity) { + return null; + } + return ScrollSpringSimulation( + _expressiveSpring, + px, + target, + velocity, + tolerance: tolerance, + ); + } + if (px >= delta && velocity < 0) { + return BouncingScrollSimulation( + position: px, + velocity: velocity, + leadingExtent: delta, + trailingExtent: math.max(delta, position.maxScrollExtent), + spring: spring, + tolerance: tolerance, + ); + } + } + return super.createBallisticSimulation(position, velocity); + } +} + +class MorphHeaderDelegate extends SliverPersistentHeaderDelegate { + final double collapsedExtent; + final double expandedExtent; + final Widget Function(BuildContext context, double t) headerBuilder; + + MorphHeaderDelegate({ + required this.collapsedExtent, + required this.expandedExtent, + required this.headerBuilder, + }); + + @override + double get minExtent => collapsedExtent; + + @override + double get maxExtent => expandedExtent; + + @override + OverScrollHeaderStretchConfiguration get stretchConfiguration => + OverScrollHeaderStretchConfiguration(); + + @override + Widget build( + BuildContext context, + double shrinkOffset, + bool overlapsContent, + ) { + final range = expandedExtent - collapsedExtent; + final t = range <= 0 ? 0.0 : (1 - shrinkOffset / range).clamp(0.0, 1.0); + return headerBuilder(context, t); + } + + @override + bool shouldRebuild(covariant MorphHeaderDelegate oldDelegate) => true; +} diff --git a/lib/frontend/widgets/sending_clock_icon.dart b/lib/frontend/widgets/sending_clock_icon.dart new file mode 100644 index 0000000..afbc08d --- /dev/null +++ b/lib/frontend/widgets/sending_clock_icon.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:lottie/lottie.dart'; + +import '../../core/config/app_animations.dart'; + +class SendingClockIcon extends StatelessWidget { + final Color color; + final double size; + + const SendingClockIcon({super.key, required this.color, this.size = 14}); + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: size, + child: Lottie.asset( + AppAnimations.clock, + repeat: true, + fit: BoxFit.contain, + delegates: LottieDelegates( + values: [ + ValueDelegate.color(const ['**'], value: color), + ValueDelegate.strokeColor(const ['**'], value: color), + ], + ), + ), + ); + } +} + +bool isSendingStatus(String? status) => + status == 'sending' || status == 'pending'; diff --git a/lib/frontend/widgets/upload_progress_ring.dart b/lib/frontend/widgets/upload_progress_ring.dart new file mode 100644 index 0000000..1b6785b --- /dev/null +++ b/lib/frontend/widgets/upload_progress_ring.dart @@ -0,0 +1,63 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +class UploadProgressRing extends StatelessWidget { + final ValueListenable> progress; + final Color color; + final Color? trackColor; + final double size; + final double strokeWidth; + final double iconSize; + final EdgeInsets padding; + + const UploadProgressRing({ + super.key, + required this.progress, + required this.color, + this.trackColor, + this.size = 54, + this.strokeWidth = 3, + this.iconSize = 22, + this.padding = EdgeInsets.zero, + }); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: progress, + builder: (context, values, _) { + final value = values.isEmpty + ? 0.0 + : values.reduce((a, b) => a + b) / values.length; + final done = value >= 1.0; + return SizedBox.square( + dimension: size, + child: Stack( + alignment: Alignment.center, + children: [ + Padding( + padding: padding, + child: SizedBox.expand( + child: TweenAnimationBuilder( + tween: Tween(end: value.clamp(0.0, 1.0)), + duration: const Duration(milliseconds: 220), + curve: Curves.easeOut, + builder: (context, shown, _) => CircularProgressIndicator( + value: done ? null : shown, + strokeWidth: strokeWidth, + backgroundColor: + trackColor ?? color.withValues(alpha: 0.25), + color: color, + ), + ), + ), + ), + Icon(Symbols.close, size: iconSize, color: color), + ], + ), + ); + }, + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 99f9849..4cbc7b9 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1029,6 +1029,8 @@ "contactBubbleNew": "New contact", "contactBubbleAlreadyAdded": "Already in your contacts", "contactBubbleOpenProfile": "Open profile", + "miniAppOpen": "Open", + "miniAppFailed": "Couldn't open the app", "editContactMenu": "Edit contact", "editContactTitle": "Edit contact", "editContactFirstName": "First name", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 49555ab..0340cdc 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4502,6 +4502,18 @@ abstract class AppLocalizations { /// **'Open profile'** String get contactBubbleOpenProfile; + /// No description provided for @miniAppOpen. + /// + /// In en, this message translates to: + /// **'Open'** + String get miniAppOpen; + + /// No description provided for @miniAppFailed. + /// + /// In en, this message translates to: + /// **'Couldn\'t open the app'** + String get miniAppFailed; + /// No description provided for @editContactMenu. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index d4eba1d..8f15f52 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2351,6 +2351,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get contactBubbleOpenProfile => 'Open profile'; + @override + String get miniAppOpen => 'Open'; + + @override + String get miniAppFailed => 'Couldn\'t open the app'; + @override String get editContactMenu => 'Edit contact'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index a207683..2b69149 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2365,6 +2365,12 @@ class AppLocalizationsRu extends AppLocalizations { @override String get contactBubbleOpenProfile => 'Открыть профиль'; + @override + String get miniAppOpen => 'Открыть'; + + @override + String get miniAppFailed => 'Не удалось открыть приложение'; + @override String get editContactMenu => 'Редактировать контакт'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index a94a85a..3969268 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -773,6 +773,8 @@ "contactBubbleNew": "Новый контакт", "contactBubbleAlreadyAdded": "Уже твой контакт", "contactBubbleOpenProfile": "Открыть профиль", + "miniAppOpen": "Открыть", + "miniAppFailed": "Не удалось открыть приложение", "editContactMenu": "Редактировать контакт", "editContactTitle": "Редактировать контакт", "editContactFirstName": "Имя", diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index ea0c5af..49110d9 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -132,6 +132,7 @@ class VideoAttachment extends MessageAttachment { final int? height; final int? duration; final int? size; + final String? localPath; final int? videoType; @@ -149,6 +150,7 @@ class VideoAttachment extends MessageAttachment { this.duration, this.size, this.videoType, + this.localPath, }) : super(type: AttachmentType.video); factory VideoAttachment.fromMap(Map map) {