import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import '../api.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; import '../../core/storage/app_database.dart'; import '../../core/utils/logger.dart'; import '../../models/informer_banner.dart'; class BannersModule { static const _snapshotKey = 'informer_banners'; static const _stateKey = 'informer_state'; static const _enabledKey = 'informer_enabled'; static const _serverFlag = 'informer-enabled'; static const int _resyncMask = 1; static const int _defaultShowTime = 86400000; final Api _api; BannersModule(this._api) { _api.pushStream .where((p) => p.opcode == Opcode.notifBanners) .listen(_handlePush); } final ValueNotifier activeBanner = ValueNotifier(null); int? _accountId; bool _enabled = true; int _updateTime = 0; int _showTime = _defaultShowTime; List _banners = const []; final Map _showState = {}; String? _lastShownId; String? _pinnedId; Future? _syncing; bool get isEnabled => _enabled; int get showTime => _showTime; List get banners => List.unmodifiable(_banners); BannerShowState stateOf(String bannerId) => _showState[bannerId] ?? const BannerShowState(); Future initFromLogin( int accountId, Map loginData, ) async { await load(accountId); final config = loginData['config']; final serverConfig = config is Map ? config['server'] : null; if (serverConfig is Map && serverConfig.containsKey(_serverFlag)) { await _setEnabled(accountId, _parseFlag(serverConfig[_serverFlag])); } if (!_enabled) return; var applied = false; final inline = loginData['banners']; if (inline is Map) { applied = await applyPayload(accountId, inline.cast()); } if (applied && !_needsResync(loginData['updates'])) return; await syncFromServer(); } Future load(int accountId) async { _accountId = accountId; _pinnedId = null; await _restoreSnapshot(accountId); await _restoreShowState(accountId); final enabled = await AppDatabase.getSyncValue(accountId, _enabledKey); _enabled = enabled != '0'; _recompute(); } Future syncFromServer() { return _syncing ??= _sync().whenComplete(() => _syncing = null); } Future applyPayload( int accountId, Map payload, ) async { final raw = payload['banners']; if (raw is! List) return false; final updateTime = _int(payload['updateTime']); if (_updateTime != 0 && updateTime != null && updateTime <= _updateTime) { return true; } final parsed = []; for (final entry in raw) { if (entry is! Map) continue; final banner = InformerBanner.fromMap(entry); if (banner != null) parsed.add(banner); } _banners = parsed; if (updateTime != null) _updateTime = updateTime; final showTime = _int(payload['showTime']); if (showTime != null && showTime > 0) _showTime = showTime; final known = parsed.map((b) => b.id).toSet(); _showState.removeWhere((id, _) => !known.contains(id)); if (_pinnedId != null && !known.contains(_pinnedId)) _pinnedId = null; if (_lastShownId != null && !known.contains(_lastShownId)) { _lastShownId = null; } await _persistSnapshot(accountId); await _persistShowState(accountId); _recompute(); return true; } Future markShown(InformerBanner banner) async { if (_pinnedId == banner.id) return; final now = DateTime.now().millisecondsSinceEpoch; final current = stateOf(banner.id); _showState[banner.id] = current.copyWith( showCounter: current.showCounter + 1, showAt: current.showAt ?? now, ); _lastShownId = banner.id; _pinnedId = banner.id; await _persistShowState(_accountId); } Future close(InformerBanner banner) async { final now = DateTime.now().millisecondsSinceEpoch; _showState[banner.id] = stateOf(banner.id).copyWith(closedAt: now); if (_pinnedId == banner.id) _pinnedId = null; await _persistShowState(_accountId); _recompute(); } Future markClicked(InformerBanner banner) async { if (!banner.closesOnClick) return; await close(banner); } void refresh() { _pinnedId = null; _recompute(); } void clear() { _accountId = null; _enabled = true; _updateTime = 0; _showTime = _defaultShowTime; _banners = const []; _showState.clear(); _lastShownId = null; _pinnedId = null; activeBanner.value = null; } Future _sync() async { final accountId = _accountId; if (accountId == null || !_enabled) return; try { final packet = await _api.sendRequest(Opcode.bannersSync, { 'bannersSync': 0, }); throwIfPacketError(packet); final payload = packet.payload; if (payload is Map) { await applyPayload(accountId, payload.cast()); } } catch (e) { logger.w('Баннеры: синхронизация не удалась: $e'); } } void _handlePush(Packet packet) { final accountId = _accountId; if (accountId == null || !_enabled) return; final payload = packet.payload; if (payload is Map && payload['banners'] is List) { unawaited(applyPayload(accountId, payload.cast())); return; } unawaited(syncFromServer()); } void _recompute() { if (!_enabled) { activeBanner.value = null; return; } final pinned = _pinnedId; if (pinned != null) { for (final banner in _banners) { if (banner.id == pinned) { activeBanner.value = banner; return; } } _pinnedId = null; } final now = DateTime.now().millisecondsSinceEpoch; var candidates = _banners.where((b) => _canShow(b, now)).toList(); if (candidates.length > 1 && _lastShownId != null) { final others = candidates.where((b) => b.id != _lastShownId).toList(); if (others.isNotEmpty) candidates = others; } if (candidates.isEmpty) { activeBanner.value = null; return; } activeBanner.value = candidates.reduce( (a, b) => b.priority > a.priority ? b : a, ); } bool _canShow(InformerBanner banner, int now) { final state = stateOf(banner.id); if (state.showCounter > 0 && state.showCounter >= banner.repeat) { return false; } final showAt = state.showAt; if (showAt != null && now - showAt > _showTime) return false; final closedAt = state.closedAt; if (closedAt != null) { if (banner.rerun <= 0) return false; if (now - closedAt <= banner.rerun) return false; } return true; } bool _needsResync(dynamic updates) { final mask = _int(updates); return mask != null && mask & _resyncMask != 0; } Future _setEnabled(int accountId, bool value) async { _enabled = value; await AppDatabase.setSyncValue(accountId, _enabledKey, value ? '1' : '0'); if (!value) activeBanner.value = null; } Future _restoreSnapshot(int accountId) async { _banners = const []; _updateTime = 0; _showTime = _defaultShowTime; final raw = await AppDatabase.getSyncValue(accountId, _snapshotKey); if (raw == null || raw.isEmpty) return; try { final map = jsonDecode(raw) as Map; final list = map['banners']; final parsed = []; if (list is List) { for (final entry in list) { if (entry is! Map) continue; final banner = InformerBanner.fromMap(entry); if (banner != null) parsed.add(banner); } } _banners = parsed; _updateTime = _int(map['updateTime']) ?? 0; final showTime = _int(map['showTime']); if (showTime != null && showTime > 0) _showTime = showTime; } catch (_) {} } Future _restoreShowState(int accountId) async { _showState.clear(); _lastShownId = null; final raw = await AppDatabase.getSyncValue(accountId, _stateKey); if (raw == null || raw.isEmpty) return; try { final map = jsonDecode(raw) as Map; _lastShownId = map['lastShowedBannerId']?.toString(); final entries = map['banners']; if (entries is Map) { entries.forEach((key, value) { if (value is Map) { _showState[key.toString()] = BannerShowState.fromJson(value); } }); } } catch (_) {} } Future _persistSnapshot(int accountId) async { await AppDatabase.setSyncValue( accountId, _snapshotKey, jsonEncode({ 'updateTime': _updateTime, 'showTime': _showTime, 'banners': _banners.map((b) => b.toJson()).toList(), }), ); } Future _persistShowState(int? accountId) async { if (accountId == null) return; await AppDatabase.setSyncValue( accountId, _stateKey, jsonEncode({ 'lastShowedBannerId': _lastShownId, 'banners': { for (final entry in _showState.entries) entry.key: entry.value.toJson(), }, }), ); } static bool _parseFlag(dynamic value) { if (value is bool) return value; if (value is num) return value != 0; if (value is String) { final v = value.trim().toLowerCase(); return v == 'true' || v == '1' || v == 'yes' || v == 'on'; } return true; } static int? _int(dynamic value) { if (value is int) return value; if (value is num) return value.toInt(); if (value is String) return int.tryParse(value); return null; } }