Initial commit: 91dj android client dev_1.0.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import '../../hj_model/splash/ads_model.dart';
|
||||
|
||||
/// `POST /im/ad` 返回 list 元素:跟 `AdBean` 字段近似,但 `position` 是
|
||||
/// 字符串 key(IM_LIST_BANNER / IM_LIST_FEED_NATIVE / IM_CHAT_NOTIFICATION_BAR
|
||||
/// / IM_CHAT_FLOATING_GIF_BALL)。
|
||||
///
|
||||
/// 不复用 `AdsInfoModel.position`(int) 是因为它跟其它广告接口耦合,
|
||||
/// 强行改类型会污染非 IM 场景,故用独立 `imPosition`(String)。
|
||||
class ImAdItem {
|
||||
String? id;
|
||||
String? title;
|
||||
String? cover;
|
||||
String? href;
|
||||
String? position;
|
||||
String? positionName;
|
||||
int? sortCode;
|
||||
|
||||
ImAdItem({
|
||||
this.id,
|
||||
this.title,
|
||||
this.cover,
|
||||
this.href,
|
||||
this.position,
|
||||
this.positionName,
|
||||
this.sortCode,
|
||||
});
|
||||
|
||||
AdsInfoModel get covertAd {
|
||||
AdsInfoModel infoModel = AdsInfoModel();
|
||||
infoModel.id = id;
|
||||
infoModel.title = title;
|
||||
infoModel.cover = cover;
|
||||
infoModel.href = href;
|
||||
infoModel.imPosition = position;
|
||||
infoModel.positionName = positionName;
|
||||
infoModel.sortCode = sortCode;
|
||||
return infoModel;
|
||||
}
|
||||
|
||||
ImAdItem.fromJson(Map<String, dynamic> json) {
|
||||
id = json['id']?.toString();
|
||||
title = json['title']?.toString();
|
||||
cover = json['cover']?.toString();
|
||||
href = json['href']?.toString();
|
||||
position = json['position']?.toString();
|
||||
positionName = json['positionName']?.toString();
|
||||
final sc = json['sortCode'];
|
||||
if (sc is int) {
|
||||
sortCode = sc;
|
||||
} else if (sc != null) {
|
||||
sortCode = int.tryParse(sc.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'cover': cover,
|
||||
'href': href,
|
||||
'position': position,
|
||||
'positionName': positionName,
|
||||
'sortCode': sortCode,
|
||||
};
|
||||
}
|
||||
|
||||
/// `POST /im/ad` 返回的广告分组(按 position 维度聚合)
|
||||
/// 后端把 position 从 int 改成了 String key(IM_LIST_BANNER 等)
|
||||
class ImAdGroup {
|
||||
String? position;
|
||||
String? positionName;
|
||||
List<ImAdItem>? list;
|
||||
|
||||
ImAdGroup({this.position, this.positionName, this.list});
|
||||
|
||||
ImAdGroup.fromJson(Map<String, dynamic> json) {
|
||||
position = json['position']?.toString();
|
||||
positionName = json['positionName']?.toString();
|
||||
if (json['list'] is List) {
|
||||
list = (json['list'] as List)
|
||||
.whereType<Map>()
|
||||
.map((e) => ImAdItem.fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'position': position,
|
||||
'positionName': positionName,
|
||||
'list': list?.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// `POST /im/ad` 顶层 data 结构:`{ groups: [...] }`
|
||||
class ImAdResp {
|
||||
List<ImAdGroup>? groups;
|
||||
|
||||
ImAdResp({this.groups});
|
||||
|
||||
ImAdResp.fromJson(Map<String, dynamic> json) {
|
||||
if (json['groups'] is List) {
|
||||
groups = (json['groups'] as List)
|
||||
.whereType<Map>()
|
||||
.map((e) => ImAdGroup.fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'groups': groups?.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_mm_core/im_core/model/model_message.dart';
|
||||
import 'package:jydj/tools_base/debug_log.dart';
|
||||
|
||||
import '../api/im_api_service.dart';
|
||||
import '../im_notification.dart';
|
||||
import '../model/im_message_resp.dart';
|
||||
import 'im_ad_item.dart';
|
||||
import 'im_ad_provider.dart';
|
||||
|
||||
/// 全局 IM 广告状态:position(String) → 当前最新 ImAdItem
|
||||
/// - 启动时主动 Pull 4 个 IM_xxx 一次性灌入;后端透传 AD_NOTIFY 再增量刷
|
||||
/// - 各页面(消息列表 / 聊天页)通过 Consumer 订阅指定 position
|
||||
/// - 关闭逻辑由 widget 本地状态控制(对齐 H5:离开重进会重新展示)
|
||||
|
||||
class ImAdManager {
|
||||
/// 后端约定的 4 个 IM 广告位标识
|
||||
static const String kPosListBanner = 'IM_LIST_BANNER'; //会话列表顶部广告
|
||||
static const String kPosListFeedNative = 'IM_LIST_FEED_NATIVE'; //会话列表拆入广告
|
||||
static const String kPosChatNotificationBar = 'IM_CHAT_NOTIFICATION_BAR'; //聊天页面顶部广告
|
||||
static const String kPosChatFloatingGifBall = 'IM_CHAT_FLOATING_GIF_BALL'; //聊天页面悬浮广告
|
||||
|
||||
/// 启动 Pull / 主动刷新时传给后端的全集
|
||||
static const List<String> kAllPositions = [
|
||||
kPosListBanner,
|
||||
kPosListFeedNative,
|
||||
kPosChatNotificationBar,
|
||||
kPosChatFloatingGifBall,
|
||||
];
|
||||
|
||||
static const String _typeAdNotify = 'AD_NOTIFY';
|
||||
|
||||
/// traceId 去重 —— 后端可能重复推同一条通知;
|
||||
/// 用最近 200 条 LRU 大小避免无限增长
|
||||
static final Set<String> _seenTraceIds = <String>{};
|
||||
static const int _maxTraceIds = 200;
|
||||
|
||||
static void onRecv(ModelMessage m) async {
|
||||
if (!m.isPassthrough) return;
|
||||
final extraRaw = m.extra;
|
||||
if (extraRaw == null || extraRaw.trim().isEmpty) return;
|
||||
|
||||
Map<String, dynamic>? payload;
|
||||
try {
|
||||
final decoded = jsonDecode(extraRaw);
|
||||
if (decoded is Map) {
|
||||
payload = Map<String, dynamic>.from(decoded);
|
||||
}
|
||||
} catch (e) {
|
||||
debugLog('Im-ad_extra parse failed: $e raw=$extraRaw');
|
||||
return;
|
||||
}
|
||||
if (payload == null) return;
|
||||
|
||||
final type = payload['type']?.toString() ?? '';
|
||||
final traceId = payload['traceId']?.toString() ?? '';
|
||||
if (traceId.isNotEmpty) {
|
||||
if (_seenTraceIds.contains(traceId)) return;
|
||||
_seenTraceIds.add(traceId);
|
||||
if (_seenTraceIds.length > _maxTraceIds) {
|
||||
_seenTraceIds.remove(_seenTraceIds.first);
|
||||
}
|
||||
}
|
||||
|
||||
if (type == _typeAdNotify) {
|
||||
try {
|
||||
final positionsRaw = payload['positions'];
|
||||
final List<String> positions = <String>[];
|
||||
if (positionsRaw is List) {
|
||||
for (final p in positionsRaw) {
|
||||
final s = p?.toString().trim() ?? '';
|
||||
if (s.isNotEmpty) positions.add(s);
|
||||
}
|
||||
}
|
||||
if (positions.isEmpty) return;
|
||||
// 收到 IM 刷新广告数据,清理已读广告数据
|
||||
await ImAdProvider().cleanReadData();
|
||||
//根据位置刷新对应位置的广告数据
|
||||
await pullImAdsOnce(pos: positions);
|
||||
ImMsgProvider().unReadStatusUpdateByAds();
|
||||
} catch (e) {
|
||||
debugLog(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动/重新登录后主动 Pull 一次全部 IM 广告位(4 个),灌进 imAdModel
|
||||
/// 后续后端透传 AD_NOTIFY 时再按 extra.positions 增量刷
|
||||
static Future<void> pullImAdsOnce({List<String> pos = kAllPositions}) async {
|
||||
try {
|
||||
ImAdResp resp = await IMApiService.getImAdList(pos);
|
||||
ImAdProvider().applyResp(resp);
|
||||
debugLog('IM-pullImAdsOnce ok groups=${resp.groups?.length ?? 0}');
|
||||
} catch (e) {
|
||||
debugLog('IM-pullImAdsOnce failed:$e');
|
||||
}
|
||||
}
|
||||
|
||||
//数据列表插入ad
|
||||
static void insertAd(
|
||||
List<IMSessionModel>? modelArr,
|
||||
List<ImAdItem> advList, {
|
||||
int adGap = 3, // 每隔 adGap 个会话插入广告
|
||||
}) {
|
||||
if (advList.isEmpty || modelArr == null) {
|
||||
return;
|
||||
}
|
||||
if (modelArr.isEmpty) {
|
||||
IMSessionModel adModel = IMSessionModel();
|
||||
adModel.adItem = advList.first;
|
||||
modelArr.add(adModel);
|
||||
return;
|
||||
}
|
||||
int countGap = 0;
|
||||
int adIndex = 0;
|
||||
modelArr.removeWhere((element) => element.isAdStyle);
|
||||
// 列表首位插入广告
|
||||
IMSessionModel firstAdModel = IMSessionModel();
|
||||
firstAdModel.adItem = advList[adIndex % advList.length];
|
||||
adIndex++;
|
||||
modelArr.insert(0, firstAdModel);
|
||||
for (int i = 0; i < modelArr.length; i++) {
|
||||
IMSessionModel model = modelArr[i];
|
||||
if (countGap >= adGap) {
|
||||
countGap = 0;
|
||||
if (model.isAdStyle) {
|
||||
adIndex++;
|
||||
} else {
|
||||
if (adIndex < advList.length) {
|
||||
IMSessionModel adModel = IMSessionModel();
|
||||
adModel.adItem = advList[adIndex % advList.length];
|
||||
adIndex++;
|
||||
modelArr.insert(i, adModel);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (model.isAdStyle) {
|
||||
countGap = 0;
|
||||
} else {
|
||||
countGap++;
|
||||
}
|
||||
}
|
||||
if (countGap == adGap && i == modelArr.length - 1) {
|
||||
if (adIndex < advList.length) {
|
||||
IMSessionModel adModel = IMSessionModel();
|
||||
adModel.adItem = advList[adIndex % advList.length];
|
||||
adIndex++;
|
||||
modelArr.add(adModel);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:jydj/hj_utils/light_model.dart';
|
||||
|
||||
import 'im_ad_item.dart';
|
||||
|
||||
/// 全局 IM 广告状态:position(String) → 当前最新 ImAdItem
|
||||
/// - 启动时主动 Pull 4 个 IM_xxx 一次性灌入;后端透传 AD_NOTIFY 再增量刷
|
||||
/// - 各页面(消息列表 / 聊天页)通过 Consumer 订阅指定 position
|
||||
/// - 关闭逻辑由 widget 本地状态控制(对齐 H5:离开重进会重新展示)
|
||||
|
||||
class ImAdProvider with ChangeNotifier {
|
||||
// 单例
|
||||
static final ImAdProvider _ins = ImAdProvider._();
|
||||
|
||||
factory ImAdProvider() => _ins;
|
||||
final String _adIdsLocalKey = "_adIdsLocalKey";
|
||||
|
||||
ImAdProvider._();
|
||||
|
||||
final List<ImAdGroup> _groups = [];
|
||||
|
||||
ImAdGroup? adByPosition(String position) {
|
||||
for (var model in _groups) {
|
||||
if (model.position == position && model.list?.isNotEmpty == true) {
|
||||
return model;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 取该位号第一条
|
||||
ImAdItem? getFirstAd(String position) => getAdList(position).isEmpty ? null : getAdList(position).first;
|
||||
|
||||
/// 取该位号全集(banner 轮播用)
|
||||
List<ImAdItem> getAdList(String position) => adByPosition(position)?.list ?? const <ImAdItem>[];
|
||||
|
||||
bool hasAd(String position) => getAdList(position).isNotEmpty;
|
||||
|
||||
/// 每次 applyResp(含一次有效推送)+1;widget 用它区分"这是哪一波推送",
|
||||
/// 关闭后下一波又能弹出来。
|
||||
int _version = 0;
|
||||
|
||||
int get version => _version;
|
||||
|
||||
/// handler 拿到 /im/ad 返回后调一次。
|
||||
void applyResp(ImAdResp data) {
|
||||
if (data.groups?.isNotEmpty != true) return;
|
||||
|
||||
for (ImAdGroup newGroup in data.groups!) {
|
||||
ImAdGroup? existAdGroup;
|
||||
for (ImAdGroup model in _groups) {
|
||||
if (model.position == newGroup.position) {
|
||||
existAdGroup = model;
|
||||
}
|
||||
}
|
||||
if (newGroup.list?.isNotEmpty == true) {
|
||||
// 按 sortCode 升序;空 sortCode 排最后
|
||||
newGroup.list!.sort((a, b) => (a.sortCode ?? 1 << 30).compareTo(b.sortCode ?? 1 << 30));
|
||||
}
|
||||
if (existAdGroup != null) {
|
||||
if (newGroup.list?.isNotEmpty == true) {
|
||||
existAdGroup.list = newGroup.list;
|
||||
}
|
||||
} else {
|
||||
_groups.add(newGroup);
|
||||
}
|
||||
}
|
||||
_version++;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future loadLocalAdIds() async {
|
||||
_clickedAdIds = await lightKV.getStringList(_adIdsLocalKey) ?? [];
|
||||
}
|
||||
|
||||
Future _saveLocalAdIds() async {
|
||||
await lightKV.setStringList(_adIdsLocalKey, _clickedAdIds);
|
||||
}
|
||||
|
||||
// 未点击的广告显示红点
|
||||
List<String> _clickedAdIds = [];
|
||||
|
||||
void notifyClickAd(String? adId) {
|
||||
if (adId == null) return;
|
||||
if (!_clickedAdIds.contains(adId)) {
|
||||
_clickedAdIds.add(adId);
|
||||
_saveLocalAdIds();
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool isUnreadAd(String? adId) {
|
||||
if (adId == null) return false;
|
||||
return !_clickedAdIds.contains(adId);
|
||||
}
|
||||
|
||||
Future cleanReadData() async {
|
||||
_clickedAdIds.clear();
|
||||
await _saveLocalAdIds();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user