Initial commit: 91dj android client dev_1.0.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-10-08 11:37:25 +07:00
co-authored by Claude Opus 5.5
commit 97dd6a739a
1262 changed files with 119813 additions and 0 deletions
+111
View File
@@ -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(),
};
}
+155
View File
@@ -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;
}
}
}
}
+101
View File
@@ -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();
}
}