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) { if (!_previews.containsKey(ownerId)) return; 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]; void clearLastViewed(int ownerId) { if (_lastViewed.remove(ownerId) == null) return; unawaited(_persistProgress()); } /// Кольца-превью, отсортированные: сначала непрочитанные, затем по времени. 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]; 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). 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 _refreshPreview(StoryPreview preview) { if (!_previews.containsKey(preview.owner.ownerId)) return; _applyPreview(preview); } 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) _refreshPreview(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 []; } } 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; 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 = 86400000, }) { return _publishMedia( media: {'_type': 'PHOTO', 'photoToken': photoToken}, settings: settings, expiration: expiration, ); } /// Публикация видео-истории. [videoToken] — токен уже загруженного видео /// (`VideoUploadInfo.token`), [durationMs] — длительность ролика в мс. Future publishVideo({ required String videoToken, int? durationMs, int settings = 1, int expiration = 86400000, }) { return _publishMedia( media: { '_type': 'VIDEO', 'videoType': 2, 'token': videoToken, 'duration': ?durationMs, }, settings: settings, expiration: expiration, ); } Future _publishMedia({ required Map media, required int settings, required int expiration, }) 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': media, '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(); } }