feat: backend баннеров
This commit is contained in:
@@ -11,6 +11,7 @@ import '../../core/storage/app_database.dart';
|
||||
import '../../core/storage/spoofing_service.dart';
|
||||
import '../../core/storage/token_storage.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import 'banners.dart';
|
||||
import 'chats.dart';
|
||||
import 'complaints.dart';
|
||||
import 'contacts.dart';
|
||||
@@ -41,6 +42,7 @@ class AccountModule {
|
||||
late final PrivacyModule _privacy = PrivacyModule(_api);
|
||||
late final ProfileModule _profile = ProfileModule(_api);
|
||||
late final TwoFactorModule _twoFactor = TwoFactorModule(_api, _profile);
|
||||
late final BannersModule banners = BannersModule(_api);
|
||||
final _loginStatusController = StreamController<LoginStatus>.broadcast();
|
||||
final _noticeController = StreamController<AccountNotice>.broadcast();
|
||||
bool _loggedIn = false;
|
||||
@@ -331,6 +333,7 @@ class AccountModule {
|
||||
ContactCache.clear();
|
||||
TranscriptionCache.clear();
|
||||
ComplaintsModule.clear();
|
||||
banners.clear();
|
||||
chats.resetForAccountSwitch();
|
||||
|
||||
logger.i('Добавление аккаунта: сессия сброшена, активный аккаунт очищен');
|
||||
@@ -345,6 +348,7 @@ class AccountModule {
|
||||
ContactCache.clear();
|
||||
TranscriptionCache.clear();
|
||||
ComplaintsModule.clear();
|
||||
banners.clear();
|
||||
chats.resetForAccountSwitch();
|
||||
|
||||
await _api.connect();
|
||||
@@ -376,6 +380,7 @@ class AccountModule {
|
||||
ContactCache.clear();
|
||||
TranscriptionCache.clear();
|
||||
ComplaintsModule.clear();
|
||||
banners.clear();
|
||||
chats.resetForAccountSwitch();
|
||||
await ContactsModule.primeCacheFromDb(accountId);
|
||||
|
||||
@@ -426,6 +431,7 @@ class AccountModule {
|
||||
ContactCache.clear();
|
||||
TranscriptionCache.clear();
|
||||
ComplaintsModule.clear();
|
||||
banners.clear();
|
||||
chats.resetForAccountSwitch();
|
||||
}
|
||||
|
||||
@@ -611,6 +617,12 @@ class AccountModule {
|
||||
}
|
||||
await chats.applyFavorites(profile.id);
|
||||
|
||||
try {
|
||||
await banners.initFromLogin(profile.id, data);
|
||||
} catch (e) {
|
||||
logger.w('Баннеры: $e');
|
||||
}
|
||||
|
||||
try {
|
||||
await _saveLoginInfo(data, profile.id);
|
||||
} catch (e) {
|
||||
|
||||
@@ -70,6 +70,25 @@ class AnimojiModule {
|
||||
return list.length <= 6 ? list : list.sublist(0, 6);
|
||||
}
|
||||
|
||||
Animoji? cached(int id) => _byId[id];
|
||||
|
||||
Future<Animoji?> fetchById(int id) async {
|
||||
final known = _byId[id];
|
||||
if (known != null) return known;
|
||||
final map = await _api.sendRequestMap(Opcode.assetsGetByIds, {
|
||||
'type': 'ANIMOJI',
|
||||
'ids': [id],
|
||||
});
|
||||
final list = map?['animojis'];
|
||||
if (list is! List) return null;
|
||||
for (final e in list) {
|
||||
if (e is! Map) continue;
|
||||
final animoji = Animoji.fromMap(e);
|
||||
if (animoji != null) _byId[animoji.id] = animoji;
|
||||
}
|
||||
return _byId[id];
|
||||
}
|
||||
|
||||
Future<void> ensureLoaded() {
|
||||
return _loading ??= _load().catchError((Object e) {
|
||||
_loading = null;
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
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<InformerBanner?> activeBanner = ValueNotifier(null);
|
||||
|
||||
int? _accountId;
|
||||
bool _enabled = true;
|
||||
int _updateTime = 0;
|
||||
int _showTime = _defaultShowTime;
|
||||
List<InformerBanner> _banners = const [];
|
||||
final Map<String, BannerShowState> _showState = {};
|
||||
String? _lastShownId;
|
||||
String? _pinnedId;
|
||||
Future<void>? _syncing;
|
||||
|
||||
bool get isEnabled => _enabled;
|
||||
int get showTime => _showTime;
|
||||
List<InformerBanner> get banners => List.unmodifiable(_banners);
|
||||
|
||||
BannerShowState stateOf(String bannerId) =>
|
||||
_showState[bannerId] ?? const BannerShowState();
|
||||
|
||||
Future<void> initFromLogin(
|
||||
int accountId,
|
||||
Map<dynamic, dynamic> 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<dynamic, dynamic>());
|
||||
}
|
||||
|
||||
if (applied && !_needsResync(loginData['updates'])) return;
|
||||
await syncFromServer();
|
||||
}
|
||||
|
||||
Future<void> 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<void> syncFromServer() {
|
||||
return _syncing ??= _sync().whenComplete(() => _syncing = null);
|
||||
}
|
||||
|
||||
Future<bool> applyPayload(
|
||||
int accountId,
|
||||
Map<dynamic, dynamic> 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 = <InformerBanner>[];
|
||||
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<void> markShown(InformerBanner banner) async {
|
||||
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<void> 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<void> 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<void> _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<dynamic, dynamic>());
|
||||
}
|
||||
} 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<dynamic, dynamic>()));
|
||||
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<void> _setEnabled(int accountId, bool value) async {
|
||||
_enabled = value;
|
||||
await AppDatabase.setSyncValue(accountId, _enabledKey, value ? '1' : '0');
|
||||
if (!value) activeBanner.value = null;
|
||||
}
|
||||
|
||||
Future<void> _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<String, dynamic>;
|
||||
final list = map['banners'];
|
||||
final parsed = <InformerBanner>[];
|
||||
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<void> _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<String, dynamic>;
|
||||
_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<void> _persistSnapshot(int accountId) async {
|
||||
await AppDatabase.setSyncValue(
|
||||
accountId,
|
||||
_snapshotKey,
|
||||
jsonEncode({
|
||||
'updateTime': _updateTime,
|
||||
'showTime': _showTime,
|
||||
'banners': _banners.map((b) => b.toJson()).toList(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user