From f4a30328ff8a0042b2c0427f8cd221fcdba18431 Mon Sep 17 00:00:00 2001 From: klockky Date: Wed, 8 Jul 2026 20:07:06 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B8=D1=81=D1=82=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D0=B8=20=E2=80=94=20=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82?= =?UTF-8?q?=D1=80,=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D0=BA=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B8=20=D0=BA=D1=8D=D1=88=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - протокол STORIES_LIST/GET_BY_OWNER/MARK/REACT/SEND + push (opcodes 0xD0–0xDC) - StoriesModule: лента, реакции, публикация фото, SQLite-кэш и позиция просмотра - полноэкранный вьюер: куб-переход, круговое открытие, drag-to-dismiss, прогресс-бары, реакции - сегментные кольца, плитка «Ваша история» с кнопкой публикации, in-app пикер медиа - резолв имён авторов через ensureContactNames/ContactCache --- lib/backend/modules/stories.dart | 383 ++++++ lib/core/protocol/opcode_map.dart | 26 + .../screens/chats/chat_list_screen.dart | 239 ++-- .../stories/story_composer_screen.dart | 224 ++++ .../screens/stories/story_owner_info.dart | 127 ++ lib/frontend/screens/stories/story_ring.dart | 396 ++++++ .../screens/stories/story_viewer_screen.dart | 1092 +++++++++++++++++ lib/main.dart | 4 + lib/models/story.dart | 328 +++++ 9 files changed, 2715 insertions(+), 104 deletions(-) create mode 100644 lib/backend/modules/stories.dart create mode 100644 lib/frontend/screens/stories/story_composer_screen.dart create mode 100644 lib/frontend/screens/stories/story_owner_info.dart create mode 100644 lib/frontend/screens/stories/story_ring.dart create mode 100644 lib/frontend/screens/stories/story_viewer_screen.dart create mode 100644 lib/models/story.dart diff --git a/lib/backend/modules/stories.dart b/lib/backend/modules/stories.dart new file mode 100644 index 0000000..9e21706 --- /dev/null +++ b/lib/backend/modules/stories.dart @@ -0,0 +1,383 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; + +import '../../core/protocol/opcode_map.dart'; +import '../../core/protocol/packet.dart'; +import '../../core/storage/app_database.dart'; +import '../../core/storage/token_storage.dart'; +import '../../core/utils/logger.dart'; +import '../../models/story.dart'; +import '../api.dart'; + +/// Работа с «Историями»: лента-кольца, полные истории владельца, отметка +/// просмотра и реакции. Кэшируется в SQLite (превью, полные истории и позиция +/// просмотра) — переживает перезапуск; истёкшие кольца отсеиваются при загрузке. +class StoriesModule { + StoriesModule(this._api); + + static const _previewsKey = 'stories_previews'; + static const _peersKey = 'stories_peers'; + static const _progressKey = 'stories_progress'; + + final Api _api; + + final Map _previews = {}; + final Map> _peerStories = {}; + + /// ownerId → storyId, на котором пользователь остановил просмотр. + final Map _lastViewed = {}; + + int? _accountId; + + StreamSubscription? _pushSub; + + /// Бампается при любом изменении лент/историй — UI слушает и перечитывает. + final ValueNotifier storiesChanged = ValueNotifier(0); + + void _bump() => storiesChanged.value++; + + Future _acc() async { + _accountId ??= await TokenStorage.getActiveAccountId(); + return _accountId; + } + + int _nowMs() => DateTime.now().millisecondsSinceEpoch; + + int _normMs(int t) => t <= 0 + ? 0 + : (t < 1000000000000 ? t * 1000 : t); + + // ── Кэш (SQLite) ─────────────────────────────────────────────────────── + + /// Загружает кэш из БД (превью/истории/позиции) и показывает мгновенно, + /// до сетевого ответа. Истёкшие кольца отбрасываются. + Future loadCache() async { + final acc = await _acc(); + if (acc == null) return; + try { + final rawPreviews = await AppDatabase.getSyncValue(acc, _previewsKey); + if (rawPreviews != null && rawPreviews.isNotEmpty) { + final list = jsonDecode(rawPreviews); + final now = _nowMs(); + if (list is List) { + for (final raw in list) { + final preview = StoryPreview.fromMap(raw); + if (preview == null || preview.isEmpty) continue; + final exp = _normMs(preview.lastStoryExpirationTime); + if (exp != 0 && exp < now) continue; + // Не затираем уже загруженные из сети (более свежие) кольца. + _previews.putIfAbsent(preview.owner.ownerId, () => preview); + } + } + } + + final rawPeers = await AppDatabase.getSyncValue(acc, _peersKey); + if (rawPeers != null && rawPeers.isNotEmpty) { + final map = jsonDecode(rawPeers); + if (map is Map) { + map.forEach((key, value) { + final ownerId = int.tryParse(key.toString()); + if (ownerId == null || value is! List) return; + if (!_previews.containsKey(ownerId)) return; + if (_peerStories.containsKey(ownerId)) return; + final stories = []; + for (final s in value) { + final story = Story.fromMap(s); + if (story != null) stories.add(story); + } + if (stories.isNotEmpty) _peerStories[ownerId] = stories; + }); + } + } + + final rawProgress = await AppDatabase.getSyncValue(acc, _progressKey); + if (rawProgress != null && rawProgress.isNotEmpty) { + final map = jsonDecode(rawProgress); + if (map is Map) { + map.forEach((key, value) { + final ownerId = int.tryParse(key.toString()); + final storyId = value is int ? value : int.tryParse('$value'); + if (ownerId != null && storyId != null) { + _lastViewed.putIfAbsent(ownerId, () => storyId); + } + }); + } + } + _bump(); + } catch (e) { + logger.w('StoriesModule.loadCache: $e'); + } + } + + Future _persistPreviews() async { + final acc = await _acc(); + if (acc == null) return; + final list = _previews.values.map((p) => p.toJson()).toList(); + await AppDatabase.setSyncValue(acc, _previewsKey, jsonEncode(list)); + } + + Future _persistPeers() async { + final acc = await _acc(); + if (acc == null) return; + final map = {}; + _peerStories.forEach((ownerId, stories) { + map['$ownerId'] = stories.map((s) => s.toJson()).toList(); + }); + await AppDatabase.setSyncValue(acc, _peersKey, jsonEncode(map)); + } + + Future _persistProgress() async { + final acc = await _acc(); + if (acc == null) return; + final map = {}; + _lastViewed.forEach((ownerId, storyId) => map['$ownerId'] = storyId); + await AppDatabase.setSyncValue(acc, _progressKey, jsonEncode(map)); + } + + // ── Позиция просмотра ────────────────────────────────────────────────── + + /// Запоминает, что у [ownerId] пользователь остановился на [storyId]. + void setLastViewed(int ownerId, int storyId) { + if (storyId == 0 || _lastViewed[ownerId] == storyId) return; + _lastViewed[ownerId] = storyId; + unawaited(_persistProgress()); + } + + int? lastViewedStoryId(int ownerId) => _lastViewed[ownerId]; + + /// Кольца-превью, отсортированные: сначала непрочитанные, затем по времени. + List get previews { + final list = _previews.values.where((p) => !p.isEmpty).toList(); + list.sort((a, b) { + if (a.hasUnread != b.hasUnread) return a.hasUnread ? -1 : 1; + return b.updateTime.compareTo(a.updateTime); + }); + return list; + } + + bool get hasAny => previews.isNotEmpty; + + StoryPreview? previewFor(int ownerId) => _previews[ownerId]; + + List? cachedStories(int ownerId) => _peerStories[ownerId]; + + /// Подписка на серверные пуши обновления колец (NOTIF_STORIES_UPDATE). + void attach() { + _pushSub ??= _api.pushStream + .where((p) => p.opcode == Opcode.notifStoriesUpdate) + .listen(_onPush); + } + + void _onPush(Packet packet) { + final payload = packet.payload; + if (payload is! Map) return; + final preview = StoryPreview.fromMap(payload['storiesPreview']); + if (preview == null) return; + _applyPreview(preview); + _bump(); + unawaited(_persistPreviews()); + } + + void _applyPreview(StoryPreview preview) { + if (preview.isEmpty) { + _previews.remove(preview.owner.ownerId); + _peerStories.remove(preview.owner.ownerId); + } else { + _previews[preview.owner.ownerId] = preview; + } + } + + /// Первая страница ленты историй. Возвращает false при ошибке/оффлайне. + Future loadFeed({int count = 20}) async { + if (_api.state != SessionState.online) return false; + try { + final packet = await _api.sendRequest(Opcode.storiesList, { + 'cursor': '', + 'count': count, + }); + throwIfPacketError(packet); + final data = packet.payload; + if (data is! Map) return false; + final rawPreviews = data['storiesPreviews']; + if (rawPreviews is List) { + _previews.clear(); + for (final raw in rawPreviews) { + final preview = StoryPreview.fromMap(raw); + if (preview != null) _applyPreview(preview); + } + } + _bump(); + unawaited(_persistPreviews()); + return true; + } catch (e) { + logger.w('StoriesModule.loadFeed: $e'); + return false; + } + } + + /// Полные истории владельца. Обновляет кэш и кольцо, возвращает список. + Future> getByOwner(StoryOwner owner) async { + if (_api.state != SessionState.online) { + return _peerStories[owner.ownerId] ?? const []; + } + try { + final packet = await _api.sendRequest(Opcode.storiesGetByOwner, { + 'owners': [owner.toMap()], + }); + throwIfPacketError(packet); + final data = packet.payload; + if (data is! Map) return _peerStories[owner.ownerId] ?? const []; + + final rawPreviews = data['storiesPreviews']; + if (rawPreviews is List) { + for (final raw in rawPreviews) { + final preview = StoryPreview.fromMap(raw); + if (preview != null) _applyPreview(preview); + } + } + + final rawPeers = data['peerStories']; + List result = const []; + if (rawPeers is List) { + for (final raw in rawPeers) { + final peer = PeerStories.fromMap(raw); + if (peer == null) continue; + _peerStories[peer.owner.ownerId] = peer.stories; + if (peer.owner.ownerId == owner.ownerId) result = peer.stories; + } + } + _bump(); + unawaited(_persistPreviews()); + unawaited(_persistPeers()); + return result; + } catch (e) { + logger.w('StoriesModule.getByOwner: $e'); + return _peerStories[owner.ownerId] ?? const []; + } + } + + /// Отметить историю просмотренной. Оптимистично поднимает readCount кольца. + Future mark(StoryOwner owner, int storyId) async { + if (_api.state != SessionState.online) return false; + try { + final ok = await _api.sendRequestOk(Opcode.storiesMark, { + 'owner': owner.toMap(), + 'storyId': storyId, + }); + if (ok) _markReadLocally(owner.ownerId); + return ok; + } catch (e) { + logger.w('StoriesModule.mark: $e'); + return false; + } + } + + void _markReadLocally(int ownerId) { + final preview = _previews[ownerId]; + if (preview == null) return; + if (preview.readCount >= preview.totalCount) return; + _previews[ownerId] = preview.copyWith(readCount: preview.readCount + 1); + _bump(); + unawaited(_persistPreviews()); + } + + /// Поставить ([reaction] != null) или снять (null) реакцию на историю. + Future react( + StoryOwner owner, + int storyId, + StoryReaction? reaction, + ) async { + if (_api.state != SessionState.online) return false; + try { + final ok = await _api.sendRequestOk(Opcode.storiesReact, { + 'owner': owner.toMap(), + 'storyId': storyId, + if (reaction != null) 'reaction': reaction.toMap(), + }); + if (ok) _applyReactionLocally(owner.ownerId, storyId, reaction); + return ok; + } catch (e) { + logger.w('StoriesModule.react: $e'); + return false; + } + } + + void _applyReactionLocally( + int ownerId, + int storyId, + StoryReaction? reaction, + ) { + final stories = _peerStories[ownerId]; + if (stories == null) return; + final idx = stories.indexWhere((s) => s.id == storyId); + if (idx < 0) return; + stories[idx] = stories[idx].copyWith( + reaction: reaction, + clearReaction: reaction == null, + ); + _bump(); + unawaited(_persistPeers()); + } + + /// Публикация фото-истории. [photoToken] — токен уже загруженного фото. + /// [settings]: 1 = видно всем, 2 = только контактам. [expiration] — TTL, сек. + /// Бросает [PacketError]/[TimeoutException] при ошибке сервера — чтобы UI + /// показал реальную причину, а не общее «не удалось». + Future publishPhoto({ + required String photoToken, + int settings = 1, + int expiration = 86400, + }) async { + if (_api.state != SessionState.online) { + throw const PacketError('Нет соединения с сервером'); + } + final cid = DateTime.now().millisecondsSinceEpoch; + final packet = await _api.sendRequest(Opcode.storiesSend, { + 'stories': [ + { + 'cid': cid, + 'settings': settings, + 'media': {'_type': 'PHOTO', 'photoToken': photoToken}, + 'expiration': expiration, + }, + ], + }); + throwIfPacketError(packet); + final data = packet.payload; + if (data is Map) { + final preview = StoryPreview.fromMap(data['storiesPreview']); + if (preview != null) _applyPreview(preview); + final rawStories = data['stories']; + if (rawStories is List) { + for (final raw in rawStories) { + final story = Story.fromMap(raw); + if (story == null) continue; + final list = _peerStories.putIfAbsent( + story.owner.ownerId, + () => [], + ); + list.add(story); + } + } + _bump(); + unawaited(_persistPreviews()); + unawaited(_persistPeers()); + } + } + + void clear() { + _previews.clear(); + _peerStories.clear(); + _lastViewed.clear(); + _accountId = null; + _bump(); + } + + void dispose() { + _pushSub?.cancel(); + _pushSub = null; + storiesChanged.dispose(); + } +} diff --git a/lib/core/protocol/opcode_map.dart b/lib/core/protocol/opcode_map.dart index cfee478..fbb9f9c 100644 --- a/lib/core/protocol/opcode_map.dart +++ b/lib/core/protocol/opcode_map.dart @@ -202,6 +202,20 @@ abstract class Opcode { static const int foldersReorder = 275; // Сортировка папок static const int foldersDelete = 276; // Удаление папки + // ── Stories ──────────────────────────────────────────────────────── + static const int storiesList = 208; // Лента историй (кольца-превью) + static const int storiesListByOwner = 209; // Превью по списку владельцев + static const int storiesGetByOwner = 210; // Полные истории владельцев + static const int storiesGetStats = 211; // Агрегированная статистика + static const int storiesGetDetailedStats = 212; // Детальная статистика + static const int storiesReact = 213; // Реакция на историю + static const int storiesMark = 214; // Отметка просмотренной + static const int storiesSend = 215; // Публикация истории + static const int notifStoriesUpdate = 216; // Обновление кольца (push) + static const int storiesEdit = 217; // Изменение настроек истории + static const int storiesDelete = 218; // Удаление историй + static const int storiesGetByStoryId = 220; // Истории по ID + // ── Human-readable names ─────────────────────────────────────────── static String name(int opcode) => _names[opcode] ?? 'UNKNOWN($opcode)'; @@ -362,5 +376,17 @@ abstract class Opcode { foldersUpdate: 'FOLDERS_UPDATE', foldersReorder: 'FOLDERS_REORDER', foldersDelete: 'FOLDERS_DELETE', + storiesList: 'STORIES_LIST', + storiesListByOwner: 'STORIES_LIST_BY_OWNER_ID', + storiesGetByOwner: 'STORIES_GET_BY_OWNER_ID', + storiesGetStats: 'STORIES_GET_STATS', + storiesGetDetailedStats: 'STORIES_GET_DETAILED_STATS', + storiesReact: 'STORIES_REACT', + storiesMark: 'STORIES_MARK', + storiesSend: 'STORIES_SEND', + notifStoriesUpdate: 'NOTIF_STORIES_UPDATE', + storiesEdit: 'STORIES_EDIT', + storiesDelete: 'STORIES_DELETE', + storiesGetByStoryId: 'STORIES_GET_BY_STORY_ID', }; } diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 8113a5d..ae0a7d0 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -46,7 +46,12 @@ import '../../../core/storage/draft_store.dart'; import '../../../core/storage/token_storage.dart'; import '../../../core/storage/chat_activity_store.dart'; import '../../../main.dart' - show accountModule, api, messagesModule, appRouteObserver; + show accountModule, api, messagesModule, storiesModule, appRouteObserver; +import '../../widgets/attachment/attachment_sheet.dart'; +import '../stories/story_composer_screen.dart'; +import '../stories/story_owner_info.dart'; +import '../stories/story_ring.dart'; +import '../stories/story_viewer_screen.dart'; class _StoriesScrollPhysics extends BouncingScrollPhysics { final bool Function() blockPositive; @@ -525,6 +530,7 @@ class _ChatListScreenState extends State }); if (state == SessionState.online) { _requestReload(); + _maybeLoadStories(); } } }); @@ -532,11 +538,14 @@ class _ChatListScreenState extends State _loginSub = accountModule.loginStatusStream.listen((status) { if (status == LoginStatus.success) { _requestReload(); + _maybeLoadStories(); } }); chats.chatsChanged.addListener(_onChatsChanged); DraftStore.instance.revision.addListener(_onDraftsChanged); AppStories.current.addListener(_onStoriesEnabledChanged); + storiesModule.storiesChanged.addListener(_onStoriesDataChanged); + _maybeLoadStories(); _typingSub = api.pushStream .where((p) => p.opcode == Opcode.notifTyping) .listen(_onTypingPush); @@ -579,10 +588,54 @@ class _ChatListScreenState extends State _storiesDockedOpen = false; _storiesAnimClosing = false; _storiesOverscrollRevealArmed = false; + } else { + _maybeLoadStories(); } setState(() {}); } + void _onStoriesDataChanged() { + if (mounted) setState(() {}); + } + + void _maybeLoadStories() { + if (!AppStories.current.value) return; + if (api.state != SessionState.online) return; + unawaited(storiesModule.loadFeed()); + } + + StoryOwnerInfo? _selfOwnerInfo() { + final p = _profile; + if (p == null) return null; + final name = [p.firstName, p.lastName] + .where((s) => s != null && s.trim().isNotEmpty) + .map((s) => s!.trim()) + .join(' '); + return StoryOwnerInfo( + name: name.isEmpty ? 'Вы' : name, + avatarUrl: p.baseUrl, + ); + } + + Map _storyOwnerOverrides() { + final me = _profile?.id; + final self = _selfOwnerInfo(); + if (me == null || self == null) return const {}; + return {me: StoryOwnerInfo(name: 'Ваша история', avatarUrl: self.avatarUrl)}; + } + + void _openStories(int index, [Offset? origin]) { + final previews = storiesModule.previews; + if (previews.isEmpty) return; + openStoryViewer( + context, + previews: previews, + initialIndex: index.clamp(0, previews.length - 1), + ownerOverrides: _storyOwnerOverrides(), + origin: origin, + ); + } + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -1073,6 +1126,7 @@ class _ChatListScreenState extends State chats.chatsChanged.removeListener(_onChatsChanged); DraftStore.instance.revision.removeListener(_onDraftsChanged); AppStories.current.removeListener(_onStoriesEnabledChanged); + storiesModule.storiesChanged.removeListener(_onStoriesDataChanged); _loginSub?.cancel(); _stateSub?.cancel(); _typingSub?.cancel(); @@ -1175,33 +1229,23 @@ class _ChatListScreenState extends State Row( children: [ if (AppStories.current.value && - _pullRatio < 0.8) + _pullRatio < 0.8 && + storiesModule.hasAny) Opacity( opacity: 1.0 - _pullRatio, - child: Container( - width: 50 * (1.0 - _pullRatio), - height: 32, - margin: const EdgeInsets.only( - right: 8, - ), - child: Stack( - children: [ - _buildFoldedStory( - cs, - 'https://i.pravatar.cc/150?u=dasha', - 0, - ), - _buildFoldedStory( - cs, - 'https://i.pravatar.cc/150?u=mastika', - 1, - ), - _buildFoldedStory( - cs, - 'https://i.pravatar.cc/150?u=stas', - 2, - ), - ], + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _openStories(0), + child: Container( + width: 50 * (1.0 - _pullRatio), + height: 32, + margin: const EdgeInsets.only( + right: 8, + ), + child: FoldedStoryStack( + previews: storiesModule.previews, + opacity: 1.0 - _pullRatio, + ), ), ), ), @@ -1260,24 +1304,7 @@ class _ChatListScreenState extends State height: 96 * _pullRatio, child: Opacity( opacity: _pullRatio, - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: 20, - ), - children: [ - _buildStoryItem( - 'Даша', - 'https://i.pravatar.cc/150?u=dasha', - true, - ), - _buildStoryItem( - 'Мастика', - 'https://i.pravatar.cc/150?u=mastika', - false, - ), - ], - ), + child: _buildStoriesRow(), ), ), Padding( @@ -2019,48 +2046,71 @@ class _ChatListScreenState extends State ); } - Widget _buildStoryItem(String name, String imageUrl, bool hasUpdate) { - final cs = Theme.of(context).colorScheme; - return Padding( - padding: const EdgeInsets.only(right: 16), - child: SizedBox( - width: 68, - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.topCenter, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.all(2.5), - decoration: BoxDecoration( - shape: BoxShape.circle, - border: hasUpdate - ? Border.all(color: cs.primary, width: 2) - : Border.all(color: cs.outlineVariant), - ), - child: CircleAvatar( - radius: 26, - backgroundImage: CachedNetworkImageProvider( - imageUrl, - maxWidth: kAvatarThumbSize, - maxHeight: kAvatarThumbSize, + Widget _buildStoriesRow() { + final previews = storiesModule.previews; + final me = _profile?.id; + final selfInfo = _selfOwnerInfo(); + final myIndex = me == null + ? -1 + : previews.indexWhere((p) => p.owner.ownerId == me); + final otherIndices = [ + for (var i = 0; i < previews.length; i++) + if (i != myIndex) i, + ]; + return ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 20), + itemCount: otherIndices.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + return StorySelfTile( + preview: myIndex >= 0 ? previews[myIndex] : null, + selfInfo: selfInfo == null + ? null + : StoryOwnerInfo( + name: 'Ваша история', + avatarUrl: selfInfo.avatarUrl, ), - ), - ), - const SizedBox(height: 6), - Text( - name, - style: TextStyle( - color: cs.onSurface, - fontSize: 11, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ), + onOpen: (center) => _openStories(myIndex < 0 ? 0 : myIndex, center), + onAdd: _composeStory, + ); + } + final gi = otherIndices[index - 1]; + return StoryRing( + preview: previews[gi], + onTap: (center) => _openStories(gi, center), + ); + }, + ); + } + + Future _composeStory() async { + await showAttachmentSheet( + context, + title: 'Новая история', + onSend: (photos, caption) async { + if (photos.isEmpty) return; + final picked = photos.first; + if (picked.item.isVideo) { + if (mounted) { + showCustomNotification( + context, + 'Видео в историях пока не поддерживается', + ); + } + return; + } + final file = + picked.editedFile ?? + picked.item.localFile ?? + await picked.item.originFile(); + if (file == null) { + if (mounted) showCustomNotification(context, 'Не удалось открыть фото'); + return; + } + if (!mounted) return; + pushSwipeable(context, (_) => StoryComposerScreen(file: file)); + }, ); } @@ -2603,25 +2653,6 @@ class _ChatListScreenState extends State ); } - Widget _buildFoldedStory(ColorScheme cs, String imageUrl, int index) { - return Positioned( - left: index * 12.0, - child: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: cs.surface, width: 2), - ), - child: CircleAvatar( - radius: 12, - backgroundImage: CachedNetworkImageProvider( - imageUrl, - maxWidth: kAvatarThumbSize, - maxHeight: kAvatarThumbSize, - ), - ), - ), - ); - } } class _StoriesUi extends ChangeNotifier { diff --git a/lib/frontend/screens/stories/story_composer_screen.dart b/lib/frontend/screens/stories/story_composer_screen.dart new file mode 100644 index 0000000..f6d26bc --- /dev/null +++ b/lib/frontend/screens/stories/story_composer_screen.dart @@ -0,0 +1,224 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../core/utils/haptics.dart'; +import '../../../main.dart' show fileUploader, messagesModule, storiesModule; +import '../../widgets/custom_notification.dart'; +import '../../widgets/primary_loading_button.dart'; + +const int _storyExpiration = 86400; + +class StoryComposerScreen extends StatefulWidget { + final File file; + + const StoryComposerScreen({super.key, required this.file}); + + @override + State createState() => _StoryComposerScreenState(); +} + +class _StoryComposerScreenState extends State { + final ValueNotifier _publishing = ValueNotifier(false); + int _audience = 1; // 1 = все, 2 = контакты + + @override + void dispose() { + _publishing.dispose(); + super.dispose(); + } + + Future _publish() async { + if (_publishing.value) return; + _publishing.value = true; + try { + final url = await messagesModule.requestPhotoUploadUrl(); + if (url == null || url.isEmpty) { + _fail('Не удалось получить адрес загрузки'); + return; + } + final segments = widget.file.uri.pathSegments; + final filename = segments.isNotEmpty ? segments.last : 'story.jpg'; + final token = await fileUploader.uploadPhoto( + Uri.parse(url), + widget.file, + filename: filename.isEmpty ? 'story.jpg' : filename, + ); + if (token == null || token.isEmpty) { + _fail('Не удалось загрузить фото'); + return; + } + await storiesModule.publishPhoto( + photoToken: token, + settings: _audience, + expiration: _storyExpiration, + ); + if (!mounted) return; + Haptics.success(); + Navigator.of(context).pop(); + showCustomNotification(context, 'История опубликована'); + storiesModule.loadFeed(); + } catch (e) { + _fail(e.toString()); + } + } + + void _fail(String message) { + if (!mounted) { + _publishing.value = false; + return; + } + Haptics.error(); + _publishing.value = false; + showCustomNotification(context, message); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + fit: StackFit.expand, + children: [ + Center( + child: Image.file(widget.file, fit: BoxFit.contain), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: Container( + height: 120, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.black54, Colors.transparent], + ), + ), + ), + ), + SafeArea( + child: Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.all(6), + child: IconButton( + icon: const Icon(Symbols.close, color: Colors.white), + onPressed: () => Navigator.of(context).maybePop(), + ), + ), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: Container( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 8), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [Colors.black87, Colors.transparent], + ), + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _AudienceToggle( + value: _audience, + onChanged: (v) { + Haptics.selection(); + setState(() => _audience = v); + }, + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: PrimaryLoadingButton( + loading: _publishing, + onPressed: _publish, + child: const Text( + 'Опубликовать', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} + +class _AudienceToggle extends StatelessWidget { + final int value; + final ValueChanged onChanged; + + const _AudienceToggle({required this.value, required this.onChanged}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(30), + border: Border.all(color: Colors.white.withValues(alpha: 0.16)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _segment(context, 1, Symbols.public, 'Все'), + _segment(context, 2, Symbols.group, 'Контакты'), + ], + ), + ); + } + + Widget _segment(BuildContext context, int v, IconData icon, String label) { + final selected = value == v; + final cs = Theme.of(context).colorScheme; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onChanged(v), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), + decoration: BoxDecoration( + color: selected ? cs.primary : Colors.transparent, + borderRadius: BorderRadius.circular(26), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 18, + color: selected ? cs.onPrimary : Colors.white70, + ), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + color: selected ? cs.onPrimary : Colors.white70, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/screens/stories/story_owner_info.dart b/lib/frontend/screens/stories/story_owner_info.dart new file mode 100644 index 0000000..a376ea5 --- /dev/null +++ b/lib/frontend/screens/stories/story_owner_info.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; + +import '../../../backend/modules/messages.dart' show ContactCache; +import '../../../core/cache/info_cache.dart'; +import '../../../main.dart' show messagesModule; +import '../../../models/story.dart'; + +class StoryOwnerInfo { + final String name; + final String? avatarUrl; + const StoryOwnerInfo({required this.name, this.avatarUrl}); +} + +StoryOwnerInfo? peekStoryOwnerInfo(StoryOwner owner) { + if (owner.isUser) { + // 1) Локальный кэш контактов (имя из адресной книги) — самый надёжный. + final cachedName = ContactCache.get(owner.ownerId); + final cachedAvatar = ContactCache.getAvatar(owner.ownerId); + if (cachedName != null && cachedName.isNotEmpty) { + return StoryOwnerInfo(name: cachedName, avatarUrl: cachedAvatar); + } + // 2) Серверный кэш ContactInfo. + final c = ContactInfoFetch.peek(owner.ownerId); + final name = c?.displayName ?? c?.firstName; + if (name != null && name.isNotEmpty) { + return StoryOwnerInfo(name: name, avatarUrl: c?.avatarUrl ?? cachedAvatar); + } + return null; + } + final chat = ChatInfoFetch.peek(owner.ownerId); + if (chat == null) return null; + final title = (chat.raw['title'] as String?)?.trim(); + if (title == null || title.isEmpty) return null; + return StoryOwnerInfo(name: title, avatarUrl: chat.raw['baseUrl'] as String?); +} + +Future fetchStoryOwnerInfo(StoryOwner owner) async { + final peeked = peekStoryOwnerInfo(owner); + if (peeked != null && peeked.name.isNotEmpty) return peeked; + + if (owner.isUser) { + // Канонический путь приложения: подтягивает имена и кладёт их в ContactCache. + await messagesModule.ensureContactNames({owner.ownerId}); + final cachedName = ContactCache.get(owner.ownerId); + final cachedAvatar = ContactCache.getAvatar(owner.ownerId); + if (cachedName != null && cachedName.isNotEmpty) { + return StoryOwnerInfo(name: cachedName, avatarUrl: cachedAvatar); + } + // Запасной путь через серверный ContactInfo. + final c = await ContactInfoFetch.get(owner.ownerId); + final name = c?.displayName ?? c?.firstName; + final avatar = c?.avatarUrl ?? cachedAvatar; + if (name != null && name.isNotEmpty) { + ContactCache.put(owner.ownerId, name); + if (avatar != null) ContactCache.putAvatar(owner.ownerId, avatar); + return StoryOwnerInfo(name: name, avatarUrl: avatar); + } + return avatar == null ? null : StoryOwnerInfo(name: '', avatarUrl: avatar); + } + + final chat = await ChatInfoFetch.get(owner.ownerId); + if (chat == null) return null; + final title = (chat.raw['title'] as String?)?.trim(); + if (title == null || title.isEmpty) return null; + return StoryOwnerInfo(name: title, avatarUrl: chat.raw['baseUrl'] as String?); +} + +/// Резолвит имя/аватар владельца истории (из кэша, с дозагрузкой) и отдаёт их +/// в [builder]. [override] позволяет подставить готовые данные (напр. свой +/// профиль) без обращения к кэшу. +class StoryOwnerBuilder extends StatefulWidget { + final StoryOwner owner; + final StoryOwnerInfo? overrideInfo; + final Widget Function(BuildContext context, StoryOwnerInfo? info) builder; + + const StoryOwnerBuilder({ + super.key, + required this.owner, + required this.builder, + this.overrideInfo, + }); + + @override + State createState() => _StoryOwnerBuilderState(); +} + +class _StoryOwnerBuilderState extends State { + StoryOwnerInfo? _info; + bool _fetching = false; + + @override + void initState() { + super.initState(); + _info = widget.overrideInfo ?? peekStoryOwnerInfo(widget.owner); + } + + @override + void didUpdateWidget(StoryOwnerBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.owner != widget.owner || + oldWidget.overrideInfo != widget.overrideInfo) { + _info = widget.overrideInfo ?? peekStoryOwnerInfo(widget.owner); + } + } + + /// Пока имя не найдено — пробуем дозагрузить при каждой перестройке. + /// Повторные попытки дешёвые: серверные запросы дросселируются кэшем + /// (TTL/бэкофф), а локальный ContactCache проверяется синхронно. Так имя + /// «дорезолвится» само, когда появится соединение или прогреются контакты. + void _ensureResolved() { + if (_info != null || _fetching) return; + _fetching = true; + fetchStoryOwnerInfo(widget.owner).then((info) { + _fetching = false; + if (!mounted || info == null) return; + setState(() => _info = info); + }).catchError((_) { + _fetching = false; + }); + } + + @override + Widget build(BuildContext context) { + if (_info == null && widget.overrideInfo == null) _ensureResolved(); + return widget.builder(context, _info); + } +} diff --git a/lib/frontend/screens/stories/story_ring.dart b/lib/frontend/screens/stories/story_ring.dart new file mode 100644 index 0000000..3315c1a --- /dev/null +++ b/lib/frontend/screens/stories/story_ring.dart @@ -0,0 +1,396 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../core/utils/haptics.dart'; +import '../../../models/story.dart'; +import '../../widgets/komet_avatar.dart'; +import 'story_owner_info.dart'; + +/// Кольцо-превью истории владельца в шапке списка чатов. +class StoryRing extends StatefulWidget { + final StoryPreview preview; + final StoryOwnerInfo? ownerOverride; + final String? selfLabel; + final void Function(Offset? center) onTap; + final double avatarRadius; + + const StoryRing({ + super.key, + required this.preview, + required this.onTap, + this.ownerOverride, + this.selfLabel, + this.avatarRadius = 26, + }); + + @override + State createState() => _StoryRingState(); +} + +class _StoryRingState extends State { + bool _pressed = false; + + void _handleTap() { + Haptics.tap(); + Offset? center; + final box = context.findRenderObject() as RenderBox?; + if (box != null && box.hasSize) { + center = box.localToGlobal(box.size.center(Offset.zero)); + } + widget.onTap(center); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final hasUnread = widget.preview.hasUnread; + final diameter = widget.avatarRadius * 2; + + return StoryOwnerBuilder( + owner: widget.preview.owner, + overrideInfo: widget.ownerOverride, + builder: (context, info) { + final name = widget.selfLabel ?? + (info?.name.isNotEmpty == true ? info!.name : '…'); + return Padding( + padding: const EdgeInsets.only(right: 16), + child: SizedBox( + width: 68, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _handleTap, + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + child: AnimatedScale( + scale: _pressed ? 0.9 : 1.0, + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + 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, + ), + ], + ), + ), + const SizedBox(height: 6), + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 11, + fontWeight: hasUnread + ? FontWeight.w600 + : FontWeight.w500, + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } +} + +/// Прерывистое кольцо: одна дуга на каждую историю; прочитанные приглушены. +class _SegmentedRingPainter extends CustomPainter { + final int total; + final int read; + final List unreadColors; + final Color readColor; + final double strokeWidth; + + _SegmentedRingPainter({ + required this.total, + required this.read, + required this.unreadColors, + required this.readColor, + required this.strokeWidth, + }); + + @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 segment = (2 * math.pi) / n; + final gap = n == 1 ? 0.0 : math.min(0.16, segment * 0.30); + final sweep = segment - gap; + + final unreadPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = n == 1 ? StrokeCap.butt : StrokeCap.round + ..shader = SweepGradient( + startAngle: 0, + endAngle: 2 * math.pi, + colors: [...unreadColors, unreadColors.first], + transform: const GradientRotation(-math.pi / 2), + ).createShader(rect); + + final readPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = n == 1 ? StrokeCap.butt : StrokeCap.round + ..color = readColor; + + for (var i = 0; i < n; i++) { + final start = -math.pi / 2 + gap / 2 + i * segment; + canvas.drawArc(rect, start, sweep, false, i < read ? readPaint : unreadPaint); + } + } + + @override + bool shouldRepaint(_SegmentedRingPainter old) => + old.total != total || + old.read != read || + old.readColor != readColor || + old.strokeWidth != strokeWidth || + !listEquals(old.unreadColors, unreadColors); +} + +/// Ведущая плитка «Ваша история»: показывает своё кольцо (если истории есть) +/// и всегда — бейдж «+» для публикации. Тап по кольцу открывает свои истории, +/// тап по «+» — композер. Если своих историй нет — вся плитка ведёт в композер. +class StorySelfTile extends StatefulWidget { + final StoryPreview? preview; + final StoryOwnerInfo? selfInfo; + final void Function(Offset? center) onOpen; + final VoidCallback onAdd; + final double avatarRadius; + + const StorySelfTile({ + super.key, + required this.onOpen, + required this.onAdd, + this.preview, + this.selfInfo, + this.avatarRadius = 26, + }); + + @override + State createState() => _StorySelfTileState(); +} + +class _StorySelfTileState extends State { + bool _pressed = false; + + bool get _hasStories => widget.preview != null; + + void _handleTap() { + Haptics.tap(); + if (!_hasStories) { + widget.onAdd(); + return; + } + Offset? center; + final box = context.findRenderObject() as RenderBox?; + if (box != null && box.hasSize) { + center = box.localToGlobal(box.size.center(Offset.zero)); + } + widget.onOpen(center); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final diameter = widget.avatarRadius * 2; + final preview = widget.preview; + + return Padding( + padding: const EdgeInsets.only(right: 16), + child: SizedBox( + width: 68, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _handleTap, + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + child: AnimatedScale( + scale: _pressed ? 0.9 : 1.0, + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: diameter + 12, + height: diameter + 12, + child: Stack( + alignment: Alignment.center, + children: [ + if (preview != null) + CustomPaint( + size: Size.square(diameter + 12), + painter: _SegmentedRingPainter( + total: preview.totalCount, + read: preview.readCount, + unreadColors: [cs.primary, cs.tertiary, cs.primary], + readColor: cs.outlineVariant, + strokeWidth: 2.8, + ), + ) + else + Container( + width: diameter + 6, + height: diameter + 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: cs.outlineVariant, + width: 2, + ), + ), + ), + Container( + width: diameter + 4, + height: diameter + 4, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: cs.surface, + ), + ), + KometAvatar( + name: widget.selfInfo?.name.isNotEmpty == true + ? widget.selfInfo!.name + : '+', + size: diameter, + imageUrl: widget.selfInfo?.avatarUrl, + ), + Positioned( + right: 1, + bottom: 1, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + Haptics.tap(); + widget.onAdd(); + }, + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: cs.surface, + ), + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: cs.primary, + ), + child: Icon( + Icons.add, + size: 14, + color: cs.onPrimary, + ), + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 6), + Text( + 'Ваша история', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// Свёрнутая мини-стопка колец, показывается в заголовке при закрытом доке. +class FoldedStoryStack extends StatelessWidget { + final List previews; + final double opacity; + + const FoldedStoryStack({ + super.key, + required this.previews, + this.opacity = 1.0, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final shown = previews.take(3).toList(); + return Opacity( + opacity: opacity.clamp(0.0, 1.0), + child: Stack( + children: [ + for (var i = 0; i < shown.length; i++) + Positioned( + left: i * 14.0, + child: StoryOwnerBuilder( + owner: shown[i].owner, + builder: (context, info) => Container( + padding: const EdgeInsets.all(1.5), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: cs.surface, + border: Border.all( + color: shown[i].hasUnread ? cs.primary : cs.outlineVariant, + width: 1.5, + ), + ), + child: KometAvatar( + name: info?.name.isNotEmpty == true ? info!.name : '?', + size: 28, + imageUrl: info?.avatarUrl, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/frontend/screens/stories/story_viewer_screen.dart b/lib/frontend/screens/stories/story_viewer_screen.dart new file mode 100644 index 0000000..a011530 --- /dev/null +++ b/lib/frontend/screens/stories/story_viewer_screen.dart @@ -0,0 +1,1092 @@ +import 'dart:convert'; +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +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 '../../../models/story.dart'; +import '../../widgets/custom_notification.dart'; +import '../../widgets/komet_avatar.dart'; +import 'story_owner_info.dart'; + +const _quickReactions = ['❤️', '🔥', '😍', '👏', '😂', '😮']; +const Duration _photoDuration = Duration(seconds: 5); + +/// Открывает вьюер историй. Если задан [origin] (глобальный центр нажатого +/// кольца) — открытие анимируется расширяющимся из этой точки кругом; иначе — +/// масштабным «зумом». +void openStoryViewer( + BuildContext context, { + required List previews, + int initialIndex = 0, + Map ownerOverrides = const {}, + Offset? origin, +}) { + Navigator.of(context).push( + PageRouteBuilder( + opaque: false, + transitionDuration: const Duration(milliseconds: 420), + reverseTransitionDuration: const Duration(milliseconds: 320), + pageBuilder: (_, _, _) => StoryViewerScreen( + previews: previews, + initialIndex: initialIndex, + ownerOverrides: ownerOverrides, + ), + transitionsBuilder: (context, animation, _, child) { + return AnimatedBuilder( + animation: animation, + child: child, + builder: (context, child) { + final closing = animation.status == AnimationStatus.reverse || + animation.status == AnimationStatus.dismissed; + // Круговое раскрытие — только на открытии; закрытие всегда + // мягким fade + scale (круг «схлопыванием» резал кадр). + if (origin != null && !closing) { + final f = Curves.easeOutCubic.transform(animation.value); + return ClipPath( + clipper: _CircleRevealClipper(center: origin, fraction: f), + child: child, + ); + } + final v = animation.value; + return Opacity( + opacity: v.clamp(0.0, 1.0), + child: Transform.scale(scale: 0.92 + 0.08 * v, child: child), + ); + }, + ); + }, + ), + ); +} + +class _CircleRevealClipper extends CustomClipper { + final Offset center; + final double fraction; + + const _CircleRevealClipper({required this.center, required this.fraction}); + + @override + Path getClip(Size size) { + final farthest = Offset( + center.dx < size.width / 2 ? size.width : 0, + center.dy < size.height / 2 ? size.height : 0, + ); + final maxRadius = (farthest - center).distance; + final radius = ui.lerpDouble(28, maxRadius, fraction.clamp(0.0, 1.0))!; + return Path()..addOval(Rect.fromCircle(center: center, radius: radius)); + } + + @override + bool shouldReclip(_CircleRevealClipper oldClipper) => + oldClipper.fraction != fraction || oldClipper.center != center; +} + +class StoryViewerScreen extends StatefulWidget { + final List previews; + final int initialIndex; + final Map ownerOverrides; + + const StoryViewerScreen({ + super.key, + required this.previews, + this.initialIndex = 0, + this.ownerOverrides = const {}, + }); + + @override + State createState() => _StoryViewerScreenState(); +} + +class _StoryViewerScreenState extends State + with SingleTickerProviderStateMixin { + late final PageController _ownerController; + late int _ownerIndex; + late final AnimationController _photoProgress; + + final Map> _stories = {}; + final Map _loading = {}; + final Set _marked = {}; + + final ValueNotifier _segment = ValueNotifier(0); + int _storyIndex = 0; + bool _paused = false; + + double _dragDy = 0; + bool _dragging = false; + static const double _dismissThreshold = 120; + + final List<_Burst> _bursts = []; + int _burstSeq = 0; + + VideoPlayerController? _video; + + StoryPreview get _owner => widget.previews[_ownerIndex]; + + List get _ownerStories => _stories[_owner.owner.ownerId] ?? const []; + + Story? get _currentStory { + final list = _ownerStories; + if (_storyIndex < 0 || _storyIndex >= list.length) return null; + return list[_storyIndex]; + } + + @override + void initState() { + super.initState(); + _ownerIndex = widget.initialIndex.clamp(0, widget.previews.length - 1); + _ownerController = PageController(initialPage: _ownerIndex); + _photoProgress = AnimationController(vsync: this, duration: _photoDuration) + ..addListener(() => _segment.value = _photoProgress.value) + ..addStatusListener((s) { + if (s == AnimationStatus.completed) _advance(); + }); + _loadOwner(_ownerIndex, autostart: true); + } + + @override + void dispose() { + _disposeVideo(); + _photoProgress.dispose(); + _segment.dispose(); + _ownerController.dispose(); + super.dispose(); + } + + void _disposeVideo() { + _video?.removeListener(_onVideoTick); + _video?.dispose(); + _video = null; + } + + Future _loadOwner(int index, {bool autostart = false}) async { + final ownerId = widget.previews[index].owner.ownerId; + if (_stories.containsKey(ownerId)) { + if (autostart) _startStory(_resumeIndex(index, _stories[ownerId]!)); + return; + } + setState(() => _loading[ownerId] = true); + final stories = await storiesModule.getByOwner(widget.previews[index].owner); + if (!mounted) return; + setState(() { + _stories[ownerId] = stories; + _loading[ownerId] = false; + }); + if (autostart && index == _ownerIndex) { + _startStory(_resumeIndex(index, stories)); + } + } + + /// Индекс, с которого начать показ: сначала — сохранённая позиция просмотра, + /// иначе — первая непрочитанная. + 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; + } + + void _startStory(int index) { + _disposeVideo(); + _photoProgress.stop(); + _segment.value = 0; + _paused = false; + setState(() => _storyIndex = index); + + final story = _currentStory; + if (story == null) return; + _markViewed(story); + storiesModule.setLastViewed(story.owner.ownerId, story.id); + + final media = story.media; + if (media != null && media.isVideo && (media.url?.isNotEmpty ?? false)) { + _startVideo(media.url!); + } else { + _photoProgress.forward(from: 0); + } + } + + Future _startVideo(String url) async { + final controller = VideoPlayerController.networkUrl(Uri.parse(url)); + _video = controller; + try { + await controller.initialize(); + if (!mounted || _video != controller) { + controller.dispose(); + return; + } + controller.addListener(_onVideoTick); + await controller.play(); + setState(() {}); + } catch (_) { + if (_video == controller) { + _disposeVideo(); + _photoProgress.forward(from: 0); + } + } + } + + void _onVideoTick() { + final c = _video; + if (c == null || !c.value.isInitialized) return; + final total = c.value.duration.inMilliseconds; + if (total <= 0) return; + _segment.value = (c.value.position.inMilliseconds / total).clamp(0.0, 1.0); + if (c.value.position >= c.value.duration && !c.value.isPlaying) { + _advance(); + } + } + + void _markViewed(Story story) { + if (story.id == 0 || _marked.contains(story.id)) return; + _marked.add(story.id); + storiesModule.mark(story.owner, story.id); + } + + void _advance() { + Haptics.selection(); + if (_storyIndex + 1 < _ownerStories.length) { + _startStory(_storyIndex + 1); + } else { + _nextOwner(); + } + } + + void _rewind() { + Haptics.selection(); + if (_storyIndex > 0) { + _startStory(_storyIndex - 1); + } else { + _prevOwner(); + } + } + + void _nextOwner() { + if (_ownerIndex + 1 < widget.previews.length) { + _ownerController.nextPage( + duration: const Duration(milliseconds: 320), + curve: Curves.easeInOutCubic, + ); + } else { + Navigator.of(context).maybePop(); + } + } + + void _prevOwner() { + if (_ownerIndex > 0) { + _ownerController.previousPage( + duration: const Duration(milliseconds: 320), + curve: Curves.easeInOutCubic, + ); + } + } + + void _onOwnerPageChanged(int index) { + _disposeVideo(); + _photoProgress.stop(); + _segment.value = 0; + setState(() { + _ownerIndex = index; + _storyIndex = 0; + }); + _loadOwner(index, autostart: true); + } + + void _setPaused(bool paused) { + if (_paused == paused) return; + setState(() => _paused = paused); + final video = _video; + if (video != null && video.value.isInitialized) { + paused ? video.pause() : video.play(); + } else { + paused ? _photoProgress.stop() : _photoProgress.forward(); + } + } + + void _spawnBurst(String emoji, Alignment from) { + final id = _burstSeq++; + setState(() => _bursts.add(_Burst(id, emoji, from))); + } + + void _removeBurst(int id) { + if (!mounted) return; + setState(() => _bursts.removeWhere((b) => b.id == id)); + } + + Future _toggleReaction(String emoji) async { + final story = _currentStory; + if (story == null || story.id == 0) return; + final isSame = story.reaction?.id == emoji; + if (!isSame) { + Haptics.medium(); + _spawnBurst(emoji, const Alignment(0, 0.55)); + } else { + Haptics.tap(); + } + final ok = await storiesModule.react( + story.owner, + story.id, + isSame ? null : StoryReaction(id: emoji), + ); + if (!mounted) return; + if (ok) { + setState(() {}); + } else { + showCustomNotification(context, 'Не удалось отправить реакцию'); + } + } + + void _onDragStart(DragStartDetails _) { + _dragging = true; + _setPaused(true); + } + + void _onDragUpdate(DragUpdateDetails d) { + setState(() => _dragDy = (_dragDy + d.delta.dy).clamp(-40.0, 600.0)); + } + + void _onDragEnd(DragEndDetails d) { + final v = d.primaryVelocity ?? 0; + if (_dragDy > _dismissThreshold || v > 700) { + Navigator.of(context).maybePop(); + return; + } + setState(() { + _dragging = false; + _dragDy = 0; + }); + _setPaused(false); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.transparent, + body: Stack( + children: [ + TweenAnimationBuilder( + tween: Tween(end: _dragDy), + duration: _dragging + ? Duration.zero + : const Duration(milliseconds: 320), + curve: Curves.easeOutCubic, + child: PageView.builder( + controller: _ownerController, + onPageChanged: _onOwnerPageChanged, + itemCount: widget.previews.length, + physics: const BouncingScrollPhysics(), + itemBuilder: (context, index) { + final content = index == _ownerIndex + ? _buildActiveOwner() + : _OwnerCover( + preview: widget.previews[index], + overrideInfo: widget.ownerOverrides[ + widget.previews[index].owner.ownerId], + ); + return _CubePage( + controller: _ownerController, + index: index, + fallbackPage: _ownerIndex.toDouble(), + child: content, + ); + }, + ), + builder: (context, dy, child) { + final p = (dy.abs() / 320).clamp(0.0, 1.0); + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: IgnorePointer( + child: ColoredBox( + color: Colors.black.withValues(alpha: 1.0 - p * 0.7), + ), + ), + ), + Transform.translate( + offset: Offset(0, dy), + child: Transform.scale( + scale: 1.0 - p * 0.12, + child: ClipRRect( + borderRadius: BorderRadius.circular(p * 26), + child: child, + ), + ), + ), + ], + ); + }, + ), + for (final burst in _bursts) + _FloatingReaction( + key: ValueKey(burst.id), + emoji: burst.emoji, + alignment: burst.from, + onDone: () => _removeBurst(burst.id), + ), + ], + ), + ); + } + + Widget _buildActiveOwner() { + final ownerId = _owner.owner.ownerId; + final loading = _loading[ownerId] ?? false; + final stories = _ownerStories; + final story = _currentStory; + + return GestureDetector( + onTapUp: (details) { + final width = MediaQuery.of(context).size.width; + if (details.localPosition.dx < width * 0.32) { + _rewind(); + } else { + _advance(); + } + }, + onLongPressStart: (_) => _setPaused(true), + onLongPressEnd: (_) => _setPaused(false), + onVerticalDragStart: _onDragStart, + onVerticalDragUpdate: _onDragUpdate, + onVerticalDragEnd: _onDragEnd, + child: Stack( + fit: StackFit.expand, + children: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 280), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: story?.media != null + ? KeyedSubtree( + key: ValueKey('$ownerId:${story!.id}'), + child: _StoryMediaView(media: story.media!, video: _video), + ) + : (loading + ? const SizedBox.expand(key: ValueKey('loading')) + : const Center( + key: ValueKey('empty'), + child: Text( + 'Историй нет', + style: TextStyle( + color: Colors.white70, + fontSize: 16, + ), + ), + )), + ), + const _TopScrim(), + if (loading) + const Center( + child: SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator( + strokeWidth: 2.4, + color: Colors.white, + ), + ), + ), + SafeArea( + child: Column( + children: [ + _buildProgressBars(stories.length), + _buildHeader(), + const Spacer(), + if (story != null) _buildReactionBar(story), + ], + ), + ), + ], + ), + ); + } + + Widget _buildProgressBars(int count) { + if (count <= 0) count = 1; + return AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: _paused ? 0.35 : 1.0, + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 10, 10, 4), + child: Row( + children: [ + for (var i = 0; i < count; i++) + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2.5), + child: _SegmentBar( + state: i < _storyIndex + ? _SegmentState.done + : i > _storyIndex + ? _SegmentState.upcoming + : _SegmentState.active, + progress: _segment, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildHeader() { + final preview = _owner; + final story = _currentStory; + return Padding( + padding: const EdgeInsets.fromLTRB(14, 8, 8, 0), + child: StoryOwnerBuilder( + owner: preview.owner, + overrideInfo: widget.ownerOverrides[preview.owner.ownerId], + builder: (context, info) => Row( + children: [ + Container( + padding: const EdgeInsets.all(1.6), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: Colors.white.withValues(alpha: 0.85), + width: 1.6, + ), + ), + child: KometAvatar( + name: info?.name.isNotEmpty == true ? info!.name : '?', + size: 34, + imageUrl: info?.avatarUrl, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + info?.name.isNotEmpty == true ? info!.name : '…', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + shadows: [ + Shadow(color: Colors.black54, blurRadius: 4), + ], + ), + ), + if (story != null && story.time > 0) + Text( + _timeAgo(story.time), + style: TextStyle( + color: Colors.white.withValues(alpha: 0.8), + fontSize: 12, + shadows: const [ + Shadow(color: Colors.black54, blurRadius: 4), + ], + ), + ), + ], + ), + ), + _RoundIconButton( + icon: Symbols.close, + onTap: () => Navigator.of(context).maybePop(), + ), + ], + ), + ), + ); + } + + Widget _buildReactionBar(Story story) { + final current = story.reaction?.id; + return Container( + padding: const EdgeInsets.only(bottom: 6), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [Colors.black54, Colors.transparent], + ), + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(30), + child: BackdropFilter( + filter: ui.ImageFilter.blur(sigmaX: 14, sigmaY: 14), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(30), + border: Border.all( + color: Colors.white.withValues(alpha: 0.18), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final emoji in _quickReactions) + _ReactionButton( + emoji: emoji, + selected: current == emoji, + onTap: () => _toggleReaction(emoji), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +// ─── Cube (3D fold) page transform ──────────────────────────────────────── +class _CubePage extends StatelessWidget { + final PageController controller; + final int index; + final double fallbackPage; + final Widget child; + + const _CubePage({ + required this.controller, + required this.index, + required this.fallbackPage, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: controller, + child: child, + builder: (context, child) { + double page = fallbackPage; + if (controller.hasClients && controller.position.haveDimensions) { + page = controller.page ?? fallbackPage; + } + final delta = (index - page).clamp(-1.0, 1.0); + final rotation = delta * (math.pi / 2.4); + final transform = Matrix4.identity() + ..setEntry(3, 2, 0.0012) + ..rotateY(rotation); + return Transform( + alignment: delta >= 0 ? Alignment.centerLeft : Alignment.centerRight, + transform: transform, + child: Stack( + fit: StackFit.expand, + children: [ + child!, + if (delta != 0) + IgnorePointer( + child: ColoredBox( + color: Colors.black.withValues( + alpha: (delta.abs() * 0.55).clamp(0.0, 0.55), + ), + ), + ), + ], + ), + ); + }, + ); + } +} + +// ─── Segmented progress bar ─────────────────────────────────────────────── +enum _SegmentState { done, active, upcoming } + +class _SegmentBar extends StatelessWidget { + final _SegmentState state; + final ValueListenable progress; + + const _SegmentBar({required this.state, required this.progress}); + + @override + Widget build(BuildContext context) { + final track = Colors.white.withValues(alpha: 0.28); + return ClipRRect( + borderRadius: BorderRadius.circular(3), + child: SizedBox( + height: 3, + child: switch (state) { + _SegmentState.done => const ColoredBox(color: Colors.white), + _SegmentState.upcoming => ColoredBox(color: track), + _SegmentState.active => ValueListenableBuilder( + valueListenable: progress, + builder: (context, value, _) => Stack( + children: [ + Positioned.fill(child: ColoredBox(color: track)), + Align( + alignment: Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: value.clamp(0.0, 1.0), + heightFactor: 1.0, + child: const DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow(color: Colors.white54, blurRadius: 4), + ], + ), + ), + ), + ), + ], + ), + ), + }, + ), + ); + } +} + +// ─── Reaction emoji button ──────────────────────────────────────────────── +class _ReactionButton extends StatefulWidget { + final String emoji; + final bool selected; + final VoidCallback onTap; + + const _ReactionButton({ + required this.emoji, + required this.selected, + required this.onTap, + }); + + @override + State<_ReactionButton> createState() => _ReactionButtonState(); +} + +class _ReactionButtonState extends State<_ReactionButton> + with SingleTickerProviderStateMixin { + late final AnimationController _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 260), + lowerBound: 0.0, + upperBound: 1.0, + value: 1.0, + ); + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + void _onTap() { + _c.forward(from: 0.0); + widget.onTap(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: _onTap, + behavior: HitTestBehavior.opaque, + child: AnimatedBuilder( + animation: _c, + builder: (context, _) { + final pop = 1.0 + math.sin(_c.value * math.pi) * 0.4; + final scale = (widget.selected ? 1.15 : 1.0) * pop; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Transform.scale( + scale: scale, + child: Text(widget.emoji, style: const TextStyle(fontSize: 28)), + ), + ); + }, + ), + ); + } +} + +// ─── Round icon button (close) ──────────────────────────────────────────── +class _RoundIconButton extends StatelessWidget { + final IconData icon; + final VoidCallback onTap; + + const _RoundIconButton({required this.icon, required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Container( + margin: const EdgeInsets.all(4), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.14), + ), + child: Icon(icon, color: Colors.white, size: 22), + ), + ); + } +} + +// ─── Top scrim ──────────────────────────────────────────────────────────── +class _TopScrim extends StatelessWidget { + const _TopScrim(); + + @override + Widget build(BuildContext context) { + return const IgnorePointer( + child: Align( + alignment: Alignment.topCenter, + child: SizedBox( + height: 150, + width: double.infinity, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.black54, Colors.transparent], + ), + ), + ), + ), + ), + ); + } +} + +// ─── Floating reaction burst ────────────────────────────────────────────── +class _Burst { + final int id; + final String emoji; + final Alignment from; + const _Burst(this.id, this.emoji, this.from); +} + +class _FloatingReaction extends StatefulWidget { + final String emoji; + final Alignment alignment; + final VoidCallback onDone; + + const _FloatingReaction({ + super.key, + required this.emoji, + required this.alignment, + required this.onDone, + }); + + @override + State<_FloatingReaction> createState() => _FloatingReactionState(); +} + +class _FloatingReactionState extends State<_FloatingReaction> + with SingleTickerProviderStateMixin { + late final AnimationController _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + ); + late final double _drift = (widget.emoji.hashCode % 40 - 20).toDouble(); + + @override + void initState() { + super.initState(); + _c.forward().whenComplete(widget.onDone); + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: AnimatedBuilder( + animation: _c, + builder: (context, _) { + final t = _c.value; + final rise = -160.0 * Curves.easeOut.transform(t); + final scale = t < 0.3 + ? Curves.easeOutBack.transform(t / 0.3) * 1.2 + : 1.2 - 0.2 * ((t - 0.3) / 0.7); + final opacity = t < 0.7 ? 1.0 : 1.0 - (t - 0.7) / 0.3; + return Align( + alignment: widget.alignment, + child: Transform.translate( + offset: Offset(_drift * t, rise), + child: Opacity( + opacity: opacity.clamp(0.0, 1.0), + child: Transform.scale( + scale: scale, + child: Text( + widget.emoji, + style: const TextStyle(fontSize: 64), + ), + ), + ), + ), + ); + }, + ), + ); + } +} + +String _timeAgo(int epochTime) { + final ms = epochTime < 1000000000000 ? epochTime * 1000 : epochTime; + final diff = (DateTime.now().millisecondsSinceEpoch - ms) ~/ 1000; + if (diff < 60) return 'только что'; + if (diff < 3600) return '${diff ~/ 60} мин'; + if (diff < 86400) return '${diff ~/ 3600} ч'; + return '${diff ~/ 86400} дн'; +} + +ImageProvider? _previewProvider(String? previewData) { + if (previewData == null) return null; + final comma = previewData.indexOf(','); + if (comma < 0) return null; + try { + return MemoryImage(base64Decode(previewData.substring(comma + 1))); + } catch (_) { + return null; + } +} + +class _StoryMediaView extends StatelessWidget { + final StoryMedia media; + final VideoPlayerController? video; + + const _StoryMediaView({required this.media, this.video}); + + @override + Widget build(BuildContext context) { + final preview = _previewProvider(media.previewData); + final Widget blurBg = preview != null + ? Positioned.fill( + child: ImageFiltered( + imageFilter: ui.ImageFilter.blur(sigmaX: 30, sigmaY: 30), + child: Image(image: preview, fit: BoxFit.cover), + ), + ) + : const SizedBox.shrink(); + + if (media.isVideo) { + final c = video; + Widget fg; + if (c != null && c.value.isInitialized) { + fg = Center( + child: AspectRatio( + aspectRatio: c.value.aspectRatio, + child: VideoPlayer(c), + ), + ); + } else if (media.thumbnailUrl?.isNotEmpty ?? false) { + fg = CachedNetworkImage( + imageUrl: media.thumbnailUrl!, + fit: BoxFit.contain, + ); + } else if (preview != null) { + fg = Center(child: Image(image: preview, fit: BoxFit.contain)); + } else { + fg = const SizedBox.shrink(); + } + return Stack( + fit: StackFit.expand, + children: [ + blurBg, + fg, + ], + ); + } + + final url = media.url; + Widget fg; + if (url == null || url.isEmpty) { + fg = preview != null + ? Center(child: Image(image: preview, fit: BoxFit.contain)) + : const SizedBox.shrink(); + } else { + fg = CachedNetworkImage( + imageUrl: url, + fit: BoxFit.contain, + fadeInDuration: const Duration(milliseconds: 200), + placeholder: preview != null + ? (context, _) => Center(child: Image(image: preview, fit: BoxFit.contain)) + : null, + errorWidget: (context, _, _) => preview != null + ? Center(child: Image(image: preview, fit: BoxFit.contain)) + : const Center( + child: Icon(Symbols.broken_image, color: Colors.white54, size: 48), + ), + ); + } + return Stack( + fit: StackFit.expand, + children: [ + blurBg, + fg, + ], + ); + } +} + +class _OwnerCover extends StatelessWidget { + final StoryPreview preview; + final StoryOwnerInfo? overrideInfo; + + const _OwnerCover({required this.preview, this.overrideInfo}); + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: Colors.black, + child: Center( + child: StoryOwnerBuilder( + owner: preview.owner, + overrideInfo: overrideInfo, + builder: (context, info) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + KometAvatar( + name: info?.name.isNotEmpty == true ? info!.name : '?', + size: 92, + imageUrl: info?.avatarUrl, + ), + const SizedBox(height: 14), + const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white30, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 3d34f96..19eebf3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -48,6 +48,7 @@ import 'backend/modules/messages.dart'; import 'backend/modules/outbox.dart'; import 'backend/modules/polls.dart'; import 'backend/modules/stickers.dart'; +import 'backend/modules/stories.dart'; import 'backend/modules/self_check.dart'; import 'backend/modules/shared_content.dart'; import 'backend/modules/webapp.dart'; @@ -80,6 +81,7 @@ final stickersModule = StickersModule(api); final webAppModule = WebAppModule(api); final digitalIdModule = DigitalIdModule(webAppModule); final fileUploader = FileUploader(api: api, messages: messagesModule); +final storiesModule = StoriesModule(api); final RouteObserver> appRouteObserver = RouteObserver>(); @@ -165,6 +167,8 @@ void main(List args) async { } attachInfoCacheApi(api); chats.attachGlobalPushHandlers(api); + storiesModule.attach(); + unawaited(storiesModule.loadCache()); unawaited(DeepLinkService.instance.init()); final packageInfoFuture = PackageInfo.fromPlatform(); diff --git a/lib/models/story.dart b/lib/models/story.dart new file mode 100644 index 0000000..0b72ad4 --- /dev/null +++ b/lib/models/story.dart @@ -0,0 +1,328 @@ +import '../core/utils/parse.dart'; +import 'attachment.dart'; + +enum StoryOwnerType { user, chat, channel } + +int _ownerTypeToInt(StoryOwnerType type) { + switch (type) { + case StoryOwnerType.user: + return 0; + case StoryOwnerType.chat: + return 1; + case StoryOwnerType.channel: + return 2; + } +} + +StoryOwnerType _ownerTypeFromInt(Object? raw) { + switch (parseIntOrNull(raw)) { + case 1: + return StoryOwnerType.chat; + case 2: + return StoryOwnerType.channel; + default: + return StoryOwnerType.user; + } +} + +Map _asStringMap(Object? raw) { + if (raw is Map) return raw; + if (raw is Map) return Map.from(raw); + return const {}; +} + +class StoryOwner { + final int ownerId; + final StoryOwnerType type; + + const StoryOwner({required this.ownerId, this.type = StoryOwnerType.user}); + + bool get isUser => type == StoryOwnerType.user; + + static StoryOwner? fromMap(Object? raw) { + final map = _asStringMap(raw); + final id = parseIntOrNull(map['ownerId']); + if (id == null || id == 0) return null; + return StoryOwner(ownerId: id, type: _ownerTypeFromInt(map['type'])); + } + + Map toMap() => { + 'ownerId': ownerId, + 'type': _ownerTypeToInt(type), + }; + + @override + bool operator ==(Object other) => + other is StoryOwner && + other.ownerId == ownerId && + other.type == type; + + @override + int get hashCode => Object.hash(ownerId, type); +} + +class StoryReaction { + final int reactionType; // 0 = emoji, 1 = sticker + final String id; + + const StoryReaction({this.reactionType = 0, required this.id}); + + bool get isSticker => reactionType == 1; + + static StoryReaction? fromMap(Object? raw) { + final map = _asStringMap(raw); + final id = map['id']?.toString(); + if (id == null || id.isEmpty) return null; + return StoryReaction( + reactionType: parseIntOrNull(map['reactionType']) ?? 0, + id: id, + ); + } + + Map toMap() => {'reactionType': reactionType, 'id': id}; +} + +class StoryMedia { + final AttachmentType type; + final String? url; + final String? thumbnailUrl; + final String? previewData; + final int? width; + final int? height; + final int? durationMs; + + const StoryMedia({ + required this.type, + this.url, + this.thumbnailUrl, + this.previewData, + this.width, + this.height, + this.durationMs, + }); + + bool get isVideo => type == AttachmentType.video; + bool get isPhoto => type == AttachmentType.photo; + + double get aspectRatio { + final w = width ?? 0; + final h = height ?? 0; + if (w <= 0 || h <= 0) return 9 / 16; + return w / h; + } + + static StoryMedia? fromMap(Object? raw) { + final map = _asStringMap(raw); + final typeStr = (map['_type'] as String? ?? '').toUpperCase(); + final previewData = decodeAttachPreview(map['previewData']); + final width = parseIntOrNull(map['width']); + final height = parseIntOrNull(map['height']); + switch (typeStr) { + case 'PHOTO': + final url = + (map['photoUrl'] ?? map['baseUrl'] ?? map['url'])?.toString(); + return StoryMedia( + type: AttachmentType.photo, + url: url, + previewData: previewData, + width: width, + height: height, + ); + case 'VIDEO': + final url = + (map['mp4Url'] ?? + map['videoUrl'] ?? + map['MP4_1080'] ?? + map['baseUrl']) + ?.toString(); + return StoryMedia( + type: AttachmentType.video, + url: url, + thumbnailUrl: map['thumbnail']?.toString(), + previewData: previewData, + width: width, + height: height, + durationMs: parseIntOrNull(map['duration']), + ); + default: + return StoryMedia( + type: AttachmentType.unknown, + previewData: previewData, + width: width, + height: height, + ); + } + } + + String get _typeName { + switch (type) { + case AttachmentType.photo: + return 'PHOTO'; + case AttachmentType.video: + return 'VIDEO'; + default: + return 'UNKNOWN'; + } + } + + Map toJson() { + final map = { + '_type': _typeName, + if (previewData != null) 'previewData': previewData, + if (width != null) 'width': width, + if (height != null) 'height': height, + }; + if (isVideo) { + if (url != null) map['mp4Url'] = url; + if (thumbnailUrl != null) map['thumbnail'] = thumbnailUrl; + if (durationMs != null) map['duration'] = durationMs; + } else { + if (url != null) map['photoUrl'] = url; + } + return map; + } +} + +class Story { + final int id; + final int cid; + final StoryOwner owner; + final int settings; + final int time; + final int updateTime; + final int expiration; + final StoryMedia? media; + final StoryReaction? reaction; + + const Story({ + required this.id, + required this.owner, + this.cid = 0, + this.settings = 0, + this.time = 0, + this.updateTime = 0, + this.expiration = 0, + this.media, + this.reaction, + }); + + Story copyWith({StoryReaction? reaction, bool clearReaction = false}) { + return Story( + id: id, + cid: cid, + owner: owner, + settings: settings, + time: time, + updateTime: updateTime, + expiration: expiration, + media: media, + reaction: clearReaction ? null : (reaction ?? this.reaction), + ); + } + + static Story? fromMap(Object? raw) { + final map = _asStringMap(raw); + final owner = StoryOwner.fromMap(map['owner']); + if (owner == null) return null; + return Story( + id: parseIntOrNull(map['id']) ?? 0, + cid: parseIntOrNull(map['cid']) ?? 0, + owner: owner, + settings: parseIntOrNull(map['settings']) ?? 0, + time: parseIntOrNull(map['time']) ?? 0, + updateTime: parseIntOrNull(map['updateTime']) ?? 0, + expiration: parseIntOrNull(map['expiration']) ?? 0, + media: StoryMedia.fromMap(map['media']), + reaction: StoryReaction.fromMap(map['reaction']), + ); + } + + Map toJson() => { + 'id': id, + 'cid': cid, + 'owner': owner.toMap(), + 'settings': settings, + 'time': time, + 'updateTime': updateTime, + 'expiration': expiration, + if (media != null) 'media': media!.toJson(), + if (reaction != null) 'reaction': reaction!.toMap(), + }; +} + +class StoryPreview { + final StoryOwner owner; + final int updateTime; + final int totalCount; + final int readCount; + final int lastStoryExpirationTime; + + const StoryPreview({ + required this.owner, + this.updateTime = 0, + this.totalCount = 0, + this.readCount = 0, + this.lastStoryExpirationTime = 0, + }); + + int get unreadCount { + final diff = totalCount - readCount; + return diff < 0 ? 0 : diff; + } + + bool get hasUnread => unreadCount > 0; + + bool get isEmpty => totalCount <= 0; + + StoryPreview copyWith({int? readCount}) => StoryPreview( + owner: owner, + updateTime: updateTime, + totalCount: totalCount, + readCount: readCount ?? this.readCount, + lastStoryExpirationTime: lastStoryExpirationTime, + ); + + static StoryPreview? fromMap(Object? raw) { + final map = _asStringMap(raw); + final owner = StoryOwner.fromMap(map['owner']); + if (owner == null) return null; + return StoryPreview( + owner: owner, + updateTime: parseIntOrNull(map['updateTime']) ?? 0, + totalCount: parseIntOrNull(map['totalCount']) ?? 0, + readCount: parseIntOrNull(map['readCount']) ?? 0, + lastStoryExpirationTime: + parseIntOrNull(map['lastStoryExpirationTime']) ?? 0, + ); + } + + Map toJson() => { + 'owner': owner.toMap(), + 'updateTime': updateTime, + 'totalCount': totalCount, + 'readCount': readCount, + 'lastStoryExpirationTime': lastStoryExpirationTime, + }; +} + +class PeerStories { + final StoryOwner owner; + final List stories; + + const PeerStories({required this.owner, this.stories = const []}); + + static PeerStories? fromMap(Object? raw) { + final map = _asStringMap(raw); + final owner = StoryOwner.fromMap(map['owner']); + if (owner == null) return null; + final rawStories = map['stories']; + final stories = []; + if (rawStories is List) { + for (final s in rawStories) { + final story = Story.fromMap(s); + if (story != null) stories.add(story); + } + } + return PeerStories(owner: owner, stories: stories); + } +}