删除埋点,im和客服

This commit is contained in:
谢宇宁
2026-10-08 20:10:10 +07:00
parent 736b4ccab2
commit 92816047c6
205 changed files with 0 additions and 22478 deletions
-144
View File
@@ -1,144 +0,0 @@
import 'package:flutter/material.dart';
import 'package:jydj/assets_tool/images.dart';
final holidayTheme = HolidayTheme._();
class HolidayTheme {
HolidayTheme._();
/// 节假日 → (主题编号, 桌面图标 index);春节不换图标,icon 为 0
static const Map<String, ({int theme, int icon})> _festivalMap = {
'默认': (theme: 1, icon: 0),
'清明节': (theme: 2, icon: 16),
'端午节': (theme: 3, icon: 17),
'劳动节': (theme: 4, icon: 18),
'春节': (theme: 5, icon: 0),
};
int themeValue = 1;
int iconIndex = 0;
final HolidayThemeModel _model = HolidayThemeModel();
static HolidayThemeModel get theme => holidayTheme._model;
/// 是否打开节假日
bool get openHoliday => themeValue > 1;
void setThemeValue(String festival) {
final cfg = _festivalMap[festival] ?? const (theme: 1, icon: 0);
themeValue = cfg.theme;
iconIndex = cfg.icon;
final m = _model;
m.current = themeValue;
m.appIcon = _p('ic_launcher.webp');
m.searchBgImg = _p('search_bg.webp');
m.addPublish = _p('publish.png');
m.vipBg = _p('mine_vip_bg.webp');
m.coin = _p('mine_gold_enter.webp');
m.agent = _p('mine_agent_enter.webp');
m.vipEnter = _p('mine_vip_enter.webp');
m.aiEnter = _p('mine_ai_enter.webp');
m.homeNor = _p('home_0_0.png');
m.homeSel = _p('home_0_1.png');
m.shortNor = _p('home_1_0.png');
m.shortSel = _p('home_1_1.png');
m.darkNor = _p('home_2_0.png');
m.darkSel = _p('home_2_1.png');
m.aiNor = _p('home_3_0.png');
m.aiSel = _p('home_3_1.png');
m.communityNor = _p('home_4_0.png');
m.communitySel = _p('home_4_1.png');
m.mineNor = _p('home_5_0.png');
m.mineSel = _p('home_5_1.png');
// 默认三块背景都用 main_bg,仅 case 3/5 单独覆盖
final mainBg = _p('main_bg.webp');
m.mainBg = mainBg;
m.findBg = mainBg;
m.mineBg = mainBg;
// 节日特有图标默认清空,避免切换主题后残留
m.signIcon = '';
m.broadcastIcon = '';
switch (themeValue) {
case 1:
m.tabSelColor = const Color(0xffFFC000);
m.tabNorColor = const Color(0x59FFFFFF);
m.searchBgColor = Colors.black.withValues(alpha: 0.08);
m.searchTxColor = const Color(0xff999999);
break;
case 2:
m.tabSelColor = const Color(0xff00BBB8);
m.tabNorColor = const Color(0xff5D6573);
m.searchBgColor = const Color(0xff41D0AB);
m.searchTxColor = Colors.white;
break;
case 3:
m.tabSelColor = const Color(0xff07738D);
m.tabNorColor = const Color(0xff5D6573);
m.searchBgColor = const Color(0xff8EC58E);
m.searchTxColor = Colors.white;
m.findBg = _p('find_bg.webp');
m.mineBg = _p('mine_bg.webp');
break;
case 4:
m.tabSelColor = const Color(0xffBE0209);
m.tabNorColor = const Color(0xff6C6C6C);
m.searchBgColor = const Color(0xffD7221D);
m.searchTxColor = Colors.white;
break;
case 5:
m.tabSelColor = const Color(0xffFFC000);
m.tabNorColor = const Color(0xff989898);
m.searchBgColor = Colors.black.withValues(alpha: 0.08);
m.searchTxColor = Colors.white;
m.mineBg = _p('mine_bg.webp');
m.signIcon = _p('sign_icon.png');
m.broadcastIcon = _p('icon_broadcast.png');
break;
}
}
String _p(String name) => '$themeValue/$name'.holidayPath;
}
class HolidayThemeModel {
int current = 1;
String appIcon = '';
String searchBgImg = '';
String addPublish = '';
String vipBg = '';
String coin = '';
String agent = '';
String vipEnter = '';
String aiEnter = '';
String homeNor = '';
String homeSel = '';
String shortNor = '';
String shortSel = '';
String darkNor = '';
String darkSel = '';
String aiNor = '';
String aiSel = '';
String communityNor = '';
String communitySel = '';
String mineNor = '';
String mineSel = '';
String mainBg = '';
String findBg = '';
String mineBg = '';
String signIcon = '';
String broadcastIcon = '';
Color tabNorColor = const Color(0x59FFFFFF);
Color tabSelColor = const Color(0xffFFC000);
Color searchBgColor = Colors.black;
Color searchTxColor = const Color(0xff999999);
}
-76
View File
@@ -1,76 +0,0 @@
import 'package:jydj/tools_base/net/base_resp_bean.dart';
import 'package:jydj/tools_base/net/http_manager.dart';
import '../im_ad/im_ad_item.dart';
import '../model/im_user_model.dart';
class IMApiService {
///重新获取 用户 imToken
static Future<ImTokenInfo?> getImToken() async {
final result = await httpManager.fetchResponseByPOST('/im/token',
param: {}, jsonTransformation: (json) => ImTokenInfo.fromJson(json));
return result.data;
}
///获取 用户 im userid
static Future<ImUserModel?> getImUserInfo({int? uid, int? imUserId}) async {
var data = {'userId': uid, 'imUserId': imUserId};
data.removeWhere((key, value) => value == null);
final result = await httpManager.fetchResponseByPOST('/im/user-id',
param: data, jsonTransformation: (json) => ImUserModel.fromJson(json));
return result.data;
}
///添加好友
static Future<ImUserModel> ensureFriend(int? uid) async {
final result = await httpManager.fetchResponseByPOST(
'/im/friend/ensure',
param: {'peerId': uid},
jsonTransformation: (json) => ImUserModel.fromJson(json),
);
return result.data;
}
///发送im消息
static Future<BaseRespBean> sendGroupMessage(int? groupId, String? content, String? image) async {
var data = {'groupId': groupId, 'content': content, 'image': image};
data.removeWhere((key, value) => value == null);
final result = await httpManager.fetchResponseByPOST('/immessage/send', param: data);
return result;
}
static Future<ImAdResp> getImAdList(List<String> positions) async {
var data = {'positions': positions};
final result = await httpManager.fetchResponseByPOST(
'/im/ad',
param: data,
jsonTransformation: (json) => ImAdResp.fromJson(json),
);
return result.data;
}
///发送私信消息(后端转发到 IM,同时扣费)
///[takeUid] 接收用户uid [content] 消息内容 [linkUrl] 附带链接 [imgUrl] 图片链接列表
static Future<BaseRespBean> sendPriLetter({
required int takeUid,
String? content,
String? linkUrl,
List<String>? imgUrl,
}) async {
var data = {
'takeUid': takeUid,
'content': content,
'linkUrl': linkUrl,
'imgUrl': imgUrl,
};
data.removeWhere((key, value) => value == null || (value is List && value.isEmpty));
final result = await httpManager.fetchResponseByPOST(
'/im/message/send',
param: data,
jsonTransformation: (json) => ImUserWallet.fromJson(json),
);
return result;
}
}
@@ -1,283 +0,0 @@
import 'package:flutter/material.dart';
import 'package:jydj/assets_tool/images.dart';
import 'package:jydj/im_manager/im_ad/im_ad_manager.dart';
import 'package:jydj/tools_base/banner/ads_item.dart';
import 'package:jydj/hj_utils/screen.dart';
import 'package:provider/provider.dart';
import '../../hj_model/splash/ads_model.dart';
import '../../tools_base/banner/ads_banner.dart';
import '../../tools_base/widget/card_swiper/src/swiper.dart';
import '../../tools_base/widget/net_image_widget.dart';
import '../../track_event_manager/track_manager.dart';
import '../im_ad/im_ad_item.dart';
import '../im_ad/im_ad_provider.dart';
import '../im_notification.dart';
class ImAdView extends StatefulWidget {
final String position;
final EdgeInsetsGeometry? padding;
final ImAdItem? adItem;
ImAdView({super.key, required this.position, this.padding, this.adItem});
@override
State<StatefulWidget> createState() {
return _ImAdViewState();
}
}
class _ImAdViewState extends State<ImAdView> {
bool _closeFloatAd = false;
List<ImAdItem> get adImList {
return ImAdProvider().adByPosition(widget.position)?.list ?? [];
}
List<AdsInfoModel> get advList {
return adImList.map((e) => e.covertAd).toList();
}
@override
Widget build(BuildContext context) {
return Consumer<ImAdProvider>(
builder: (context, provider, child) {
if (widget.position == ImAdManager.kPosChatFloatingGifBall) {
return _buildFloatStyle();
} else if (widget.position == ImAdManager.kPosListBanner) {
return _buildBannerStyle();
} else if (widget.position == ImAdManager.kPosListFeedNative) {
return _buildInsertCellStyle();
} else if (widget.position == ImAdManager.kPosChatNotificationBar) {
return _buildDetailTopStyle();
} else {
return SizedBox();
}
},
);
}
Widget _buildDetailTopStyle() {
if (_closeFloatAd) return SizedBox();
final List<ImAdItem> items = widget.adItem != null ? [widget.adItem!] : adImList;
if (items.isEmpty) return SizedBox();
return Container(
height: 68,
color: Color(0x1AE5365C),
padding: widget.padding ?? EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
Expanded(
child: Swiper(
scrollDirection: Axis.vertical,
axisDirection: AxisDirection.down,
autoplay: items.length > 1,
autoplayDelay: 3000,
loop: items.length > 1,
itemCount: items.length,
itemBuilder: (context, index) {
final adImItem = items[index];
return AdsItem(
adInfo: adImItem.covertAd,
exposureModel: AdExposureModel(pageType: TrackEventPageType.imChatDetail, adType: AdType.smallIcon),
child: _buildDetailTopAdRow(adImItem),
);
},
),
),
2.sizeBoxW,
InkWell(
onTap: () {
_closeFloatAd = true;
setState(() {});
},
child: Container(
padding: EdgeInsets.fromLTRB(6, 4, 4, 6),
child: Image.asset("ad_close.png".commonImgPath, width: 10),
),
),
],
),
);
}
Widget _buildDetailTopAdRow(ImAdItem adImItem) {
return Row(
children: [
NetworkImageLoader(
imageUrl: adImItem.cover ?? "",
width: 48,
height: 48,
borderRadius: 48,
),
12.sizeBoxW,
Text(
adImItem.title ?? "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.white.withValues(alpha: .9),
),
),
6.sizeBoxW,
Container(
padding: EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .1),
borderRadius: BorderRadius.circular(6),
),
child: Text(
"广告",
style: TextStyle(fontSize: 10, color: Colors.white.withValues(alpha: .9)),
),
),
Spacer(),
Container(
width: 48,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Color(0xffFFC000),
borderRadius: BorderRadius.circular(24),
),
child: Text(
"领取",
style: TextStyle(fontSize: 12, color: Color(0xFFFFFFFF)),
),
),
],
);
}
Widget _buildInsertCellStyle() {
if (widget.adItem == null) return SizedBox();
bool isShowRed = ImAdProvider().isUnreadAd(widget.adItem!.id);
return AdsItem(
adInfo: widget.adItem!.covertAd,
exposureModel: AdExposureModel(pageType: TrackEventPageType.imChatDetail, adType: AdType.smallIcon),
onTap: () {
ImAdProvider().notifyClickAd(widget.adItem!.id);
ImMsgProvider().setUnReadStatusFromAd();
},
child: Container(
height: 68,
color: Color(0x1AE5365C),
padding: widget.padding ?? EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
NetworkImageLoader(
imageUrl: widget.adItem?.cover ?? "",
width: 48,
height: 48,
borderRadius: 48,
),
12.sizeBoxW,
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
widget.adItem?.title ?? "",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
if (isShowRed)
Container(
width: 10,
height: 10,
margin: EdgeInsets.only(bottom: 6, left: 4),
decoration: BoxDecoration(
color: Color(0xffF52C56),
borderRadius: BorderRadius.circular(10),
),
),
Spacer(),
Text(
"广告",
style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: .55)),
),
],
),
Text(
"",
maxLines: 1,
style: TextStyle(fontSize: 14, color: Color(0x8CFFFFFF)),
),
],
),
),
],
),
),
);
}
Widget _buildBannerStyle() {
if (advList.isEmpty) return SizedBox();
return Container(
padding: widget.padding,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: AspectRatio(
aspectRatio: 720 / 150,
child: AdsBanner(
advList,
exposureModel: AdExposureModel(pageType: TrackEventPageType.imChatCenter, adType: AdType.banner),
),
),
),
);
}
Widget _buildFloatStyle() {
if (_closeFloatAd || advList.isEmpty) return SizedBox();
return SizedBox(
height: 60,
width: 60,
child: Stack(
alignment: Alignment.bottomLeft,
children: [
SizedBox(
height: 60,
width: 60,
child: Swiper(
index: 0,
autoplay: (adImList.length > 1),
autoplayDelay: 3000,
loop: adImList.length == 1 ? false : true,
itemBuilder: (BuildContext context, int index) {
ImAdItem adItem = adImList[index];
return AdsItem(
adInfo: adItem.covertAd,
showType: AdShowType.img,
borderRadius: 70,
exposureModel: AdExposureModel(pageType: TrackEventPageType.imChatDetail, adType: AdType.smallIcon),
);
},
itemCount: adImList.length,
),
),
Positioned(
top: 0,
right: 0,
child: InkWell(
onTap: () {
_closeFloatAd = true;
setState(() {});
},
child: Image.asset("ad_close.png".commonImgPath, width: 15),
),
),
],
),
);
}
}
@@ -1,382 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_mm_core/im_core.dart';
import 'package:get/get.dart';
import 'package:jydj/hj_model/user/wallet_model.dart';
import 'package:jydj/tools_base/debug_log.dart';
import 'package:jydj/tools_base/file_upload/file_upload_tool.dart';
import 'package:jydj/tools_base/file_upload/upload_result_model.dart';
import 'package:jydj/tools_base/global_store/store.dart';
import 'package:jydj/im_manager/api/im_api_service.dart';
import 'package:jydj/im_manager/chat_detail_page/widget/input_sheet_view.dart';
import 'package:jydj/im_manager/im_manager.dart';
import 'package:jydj/routers/jump_router.dart';
import 'package:jydj/tools_base/toast.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import '../im_notification.dart';
import '../model/im_message_resp.dart';
import '../model/im_user_model.dart';
enum IMConnectStatus {
isLoading,
success,
fail,
}
class ChatDetailLogic extends GetxController with WidgetsBindingObserver {
final int sessionId;
final String? peerAvatar;
ChatDetailLogic(this.sessionId, {this.peerAvatar});
/// 聊天页可见性(跨端契约 A5):
/// - 前台 + 页面在栈顶 → true(收到对端消息自动 markRead)
/// - 后台 / paused / inactive → false(不 markRead,未读累积)
bool _isPageVisible = true;
/// SDK 单聊会话主键格式:`${otherId}_${chatType}`(1v1 chatType=0)
String get _primaryId => '${sessionId}_0';
ModelUserInfo? imPeerModel; // IM服务用户信息
ImUserModel? peerUserModel; // 本地业务用户信息
IMConnectStatus imStatus = IMConnectStatus.isLoading;
List<IMMessageModel>? msgList;
// pullYsRefresh 内部创建并释放,这里仅持有引用用于翻页状态回调
RefreshController? refreshController;
final ScrollController scrollController = ScrollController();
TextEditingController textEditController = TextEditingController();
Rx<String?> selectedImagePath = Rx<String?>(null);
RxBool isSendingMsg = false.obs;
late StreamSubscription _subscription;
bool _hasNext = true; // 是否还有更早的历史消息
bool _isLoadingData = false; // 翻页加载中,防重复触发
WalletModel? get wallet => globalStore.wallet;
int get sendMsgPrice => globalStore.meInfo?.sendMsgPrice ?? 0;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final wasVisible = _isPageVisible;
_isPageVisible = state == AppLifecycleState.resumed;
if (!wasVisible && _isPageVisible) {
// 后台 → 前台:补一次 markRead 同步累积的未读到服务端
_refreshConversationAndMarkRead();
}
}
@override
void onReady() {
globalStore.refreshWallet();
initImEvn();
_subscription = imEventBus.on<IMMsgEvent>(_receiveMsg);
scrollController.addListener(_scrollListener);
super.onReady();
}
// 滚动到距顶部半屏内时提前预加载更早消息,避免拉到边缘才触发的生硬感
void _scrollListener() {
if (!scrollController.hasClients) return;
final maxExtent = scrollController.position.maxScrollExtent;
final gapToTop = maxExtent - scrollController.offset;
if (maxExtent > 30 && _hasNext && gapToTop < Get.height / 2) {
loadMoreData();
}
}
void initImEvn() async {
var isConnect = await ImManager().connectServer();
if (isConnect == false) {
imStatus = IMConnectStatus.fail;
update();
return;
}
await _loadPeerImUserId();
await _loadMsgData();
_refreshConversationAndMarkRead();
imStatus = IMConnectStatus.success;
update();
}
Future<void> _loadPeerImUserId() async {
// 拉对方资料(imToken sdk 走 ImCore.getUserInfoById)
try {
imPeerModel = await ImCore.getUserInfoById(sessionId);
peerUserModel = await IMApiService.getImUserInfo(imUserId: sessionId);
} catch (e) {
debugLog('IM- loadPeerInfo:$e');
}
}
void _fillMessageAvatar(IMMessageModel model) {
if (model.fromSelf == true) {
model.senderNickName = globalStore.meInfo?.name;
model.senderAvatar = globalStore.meInfo?.portrait;
} else {
model.receiverNickName = imPeerModel?.name;
model.receiverAvatar = peerAvatar;
}
}
void refreshData() {
_loadMsgData();
}
void loadMoreData() {
if (msgList?.isNotEmpty == true) {
_loadMsgData(beforeTimestamp: msgList!.last.createdAt);
} else {
refreshController?.loadComplete();
}
}
Future _loadMsgData({int? beforeTimestamp, int size = 20}) async {
if (_isLoadingData) return;
_isLoadingData = true;
try {
List<ModelMessage> retList = await ImCore.loadMessages(
sessionId.toString(),
beforeTimestamp: beforeTimestamp,
limit: size,
);
debugLog(jsonEncode(retList
.map((m) => {
'chatMessageType': m.chatMessageType,
'senderID': m.senderID,
'receiverID': m.receiverID,
'content': m.content,
'extra': m.extra,
'sequence': m.sequence,
'isGroup': m.isGroup,
'timestamp': m.timestamp,
'readingStatus': m.readingStatus,
'recall': m.recall,
'url': m.url,
'localPath': m.localPath,
'size': m.size,
'width': m.width,
'height': m.height,
'length': m.length,
'thumbURL': m.thumbURL,
'messageState': m.messageState,
'messageId': m.messageId,
'localId': m.localId,
'quoteMsgId': m.quoteMsgId,
'quoteMsgState': m.quoteMsgState,
})
.toList()));
msgList ??= [];
if (beforeTimestamp == null) {
msgList?.clear();
}
var convertList = retList.map((e) => IMMessageModel.convertModel(e)).toList();
for (var model in convertList) {
_fillMessageAvatar(model);
}
msgList?.addAll(convertList);
if (retList.length == size) {
_hasNext = true;
refreshController?.loadComplete();
} else {
_hasNext = false;
refreshController?.loadNoData();
}
} catch (e) {
msgList ??= [];
debugLog(e);
refreshController?.loadComplete();
}
refreshController?.refreshCompleted();
_isLoadingData = false;
update();
}
Future readySendMsg({bool? isSend}) async {
if (isSend == true && (textEditController.text.isNotEmpty || selectedImagePath.value?.isNotEmpty == true)) {
// 点击发送按钮有内容,直接发送内容, 否则弹出输入框
sendMsgEvent();
} else {
final result = await Get.bottomSheet(
InputSheetView(
hint: sendMsgPrice > 0 ? "私信需要消费$sendMsgPrice金币" : "请输入内容",
textEditController: textEditController,
selectedImgPath: selectedImagePath.value,
imgCallback: (value) {
selectedImagePath.value = value;
},
),
backgroundColor: Colors.transparent,
barrierColor: Colors.transparent,
persistent: false,
isScrollControlled: true,
enableDrag: false,
settings: RouteSettings(name: 'im_chat_input'),
useRootNavigator: true,
);
update();
if (result == true) {
sendMsgEvent();
}
}
}
/// 发送消息(HTTP)。[directImagePath] 非空时为"选图即发":只发该图片,不读输入框/不动预览
/// 发送成功后消息由服务端回推,经 [_receiveMsg] 上屏(不做本地乐观插入)
Future sendMsgEvent({String? directImagePath}) async {
final bool isDirectImage = directImagePath?.isNotEmpty == true;
final String content = isDirectImage ? '' : textEditController.text;
final String? localImg = isDirectImage ? directImagePath : selectedImagePath.value;
if (content.isEmpty && localImg?.isNotEmpty != true) {
showToast("请输入消息内容");
return;
}
if (sendMsgPrice > (wallet?.amount ?? 0)) {
showToast("余额不足, 请先充值");
pushToWalletPage(tabPosition: 1);
return;
}
if (isSendingMsg.value) return;
isSendingMsg.value = true;
String? sendImagePath;
if (localImg?.isNotEmpty == true) {
try {
ImageUploadResultModel? imgResult = await FileUploadTool().uploadImage(localImg!);
sendImagePath = imgResult?.coverImg ?? "";
if (sendImagePath.isEmpty) {
isSendingMsg.value = false;
showToast("图片上传失败!");
return;
}
} catch (e) {
debugLog(e);
isSendingMsg.value = false;
showToast("图片上传失败!");
return;
}
}
if (peerUserModel?.userId == null) {
showToast("消息发送失败!");
isSendingMsg.value = false;
return;
}
try {
final bool isImageType = sendImagePath?.isNotEmpty == true;
final String textContent = content;
final resp = await IMApiService.sendPriLetter(
takeUid: peerUserModel!.userId!,
content: isImageType ? null : textContent,
imgUrl: isImageType ? [sendImagePath!] : null,
);
if (resp.isSuccess) {
if (!isDirectImage) {
textEditController.text = "";
selectedImagePath.value = null;
}
globalStore.refreshWallet(); // 扣费后刷新余额
if (textContent.isNotEmpty && isImageType) {
// 发送图片再发文本
await IMApiService.sendPriLetter(
takeUid: peerUserModel!.userId!,
content: textContent,
);
}
} else {
showToast(resp.toast.isNotEmpty ? resp.toast : "消息发送失败!");
}
} catch (e) {
debugLog('sendMsg: $e');
showToast("消息发送失败!");
}
isSendingMsg.value = false;
update();
}
void _receiveMsg(IMMsgEvent event) {
debugLog("IM-消息 --");
if (event.msgModel == null) return;
IMMessageModel msgModel = event.msgModel!;
if (msgModel.receiverId == sessionId.toString() || msgModel.senderId == sessionId.toString()) {
debugLog("当前聊天页面 发送/收到消息");
if (event.model!.messageState == 2) {
bool isExist = false;
if (msgModel.receiverId == sessionId.toString()) {
// 自己发的消息回推:防重(收到连续两次回调的情况)
for (int i = 0; i < min((msgList ?? []).length, 6); i++) {
if (msgList![i].messageId == msgModel.messageId) {
isExist = true;
}
}
}
if (!isExist) {
msgList ??= [];
_fillMessageAvatar(msgModel);
msgList!.insert(0, msgModel);
}
} else if (event.model!.messageState == 1) {
// 失败
} else {}
if (_isPageVisible) {
// A4:聊天页可见时收到对端消息自动 markRead,未读不累积
// A5:可见性门禁;后台/inactive 不主动 markRead,等回前台补一次
final seq = msgModel.seq ?? 0;
if (seq > 0) {
unawaited(_markConversationRead(seq: msgModel.seq ?? 0));
}
}
}
update();
}
// 发送会话通知,消息已读,取消会话红点
Future<void> _markConversationRead({required int seq}) async {
try {
await ImCore.setConversationRead(_primaryId);
} catch (e) {
debugLog('_markConversationRead local: $e');
}
if (seq <= 0) return;
try {
await ImCore.sendConversationRead(receiverId: sessionId, seq: seq);
} catch (e) {
debugLog('_markConversationRead server: $e');
}
}
Future<void> _refreshConversationAndMarkRead() async {
final c = await _fetchCurSession();
if (c == null) return;
await _markConversationRead(seq: c.seq);
}
Future<ModelConversation?> _fetchCurSession() async {
try {
ModelConversationListResult sessionRet = await ImCore.getConversations(
includeArchivedCount: false,
includeTotalUnreadCount: false,
);
for (final target in sessionRet.conversations) {
if (target.targetId == sessionId.toString()) {
return target;
}
}
} catch (e) {
debugLog('_fetchCurSession server: $e');
}
return null;
}
@override
void onClose() {
_subscription.cancel();
scrollController.removeListener(_scrollListener);
scrollController.dispose();
super.onClose();
}
}
@@ -1,100 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:jydj/tools_base/loading/loading_center_widget.dart';
import 'package:jydj/tools_base/refresh/pull_refresh.dart';
import 'package:jydj/im_manager/chat_detail_page/Im_ad_view.dart';
import 'package:jydj/im_manager/chat_detail_page/widget/input_foot_view.dart';
import 'package:jydj/im_manager/im_ad/im_ad_manager.dart';
import '../model/im_message_resp.dart';
import 'chat_detail_logic.dart';
import 'widget/chat_item_cell.dart';
class ChatDetailPage extends StatelessWidget {
final int imUserId; // IM 用户 id
final String? userName;
final int? uid; // 业务用户 uid
final String? peerAvatar; // 对方头像
const ChatDetailPage({
required this.imUserId,
required this.userName,
this.uid,
this.peerAvatar,
super.key,
});
@override
Widget build(BuildContext context) {
return GetBuilder<ChatDetailLogic>(
init: ChatDetailLogic(imUserId, peerAvatar: peerAvatar),
tag: "$imUserId",
builder: (logic) {
final peerUid = logic.peerUserModel?.userId ?? logic.peerUserModel?.peerId ?? uid;
return Scaffold(
appBar: AppBar(title: Text(userName ?? "")),
body: Stack(
children: [
if (logic.imStatus == IMConnectStatus.isLoading)
LoadingCenterWidget()
else if (logic.imStatus == IMConnectStatus.fail)
CErrorWidget(
errorMsg: "连接用户失败",
retryOnTap: () => logic.initImEvn(),
)
else
_buildContent(logic, peerUid),
Positioned(
right: 12,
bottom: 103,
child: ImAdView(position: ImAdManager.kPosChatFloatingGifBall),
),
],
),
);
},
);
}
Widget _buildContent(ChatDetailLogic logic, int? peerUid) {
return Column(
children: [
ImAdView(position: ImAdManager.kPosChatNotificationBar),
Expanded(
child: pullYsRefresh(
onInit: (ctr) => logic.refreshController = ctr,
enablePullDown: false,
enablePullUp: false, // 翻页靠滚动预加载,不用上拉 footer(避免顶部空一块)
child: ListView.builder(
controller: logic.scrollController,
itemCount: logic.msgList?.length ?? 0,
reverse: true,
padding: EdgeInsets.only(bottom: 12, top: 12),
itemBuilder: (context, index) {
IMMessageModel model = logic.msgList![index];
return ChatItemCell(model: model, peerUid: peerUid);
},
),
),
),
Obx(
() => InputFootView(
imagePath: logic.selectedImagePath.value,
isSendStatus: logic.isSendingMsg.value,
placeholder: logic.sendMsgPrice > 0 ? "私信需要消费${logic.sendMsgPrice}金币" : "请输入内容",
imgCallback: (value) {
logic.selectedImagePath.value = value;
},
onImageSend: (path) {
logic.sendMsgEvent(directImagePath: path);
},
begainCommentHandler: (value) {
logic.readySendMsg(isSend: value);
},
textEditController: logic.textEditController,
),
),
],
);
}
}
@@ -1,94 +0,0 @@
import 'dart:async';
import 'package:flutter_mm_core/im_core.dart';
import 'package:get/get.dart';
import 'package:jydj/tools_base/debug_log.dart';
import 'package:jydj/im_manager/im_ad/im_ad_manager.dart';
import 'package:jydj/im_manager/im_ad/im_ad_provider.dart';
import 'package:jydj/tools_base/toast.dart';
import '../im_manager.dart';
import '../model/im_message_resp.dart';
import 'chat_detail_logic.dart';
class ChatSessionListLogic extends GetxController {
ChatSessionListLogic();
List<IMSessionModel>? imSessionList;
IMConnectStatus imStatus = IMConnectStatus.isLoading;
@override
void onInit() {
ImCore.addConversationChangedListener(_listerIMConversation);
_loadData();
super.onInit();
}
void onInitData() {
imStatus = IMConnectStatus.isLoading;
imSessionList = null;
update();
_loadData();
}
Future _loadData() async {
try {
var isConnect = await ImManager().connectServer();
if (isConnect == false) {
imStatus = IMConnectStatus.fail;
showToast('消息连接失败,请稍后再试');
} else {
imStatus = IMConnectStatus.success;
ModelConversationListResult sessionRet = await ImCore.getConversations(
includeArchivedCount: false,
includeTotalUnreadCount: false,
);
imSessionList = IMSessionModel.covertData(sessionRet);
ImAdManager.insertAd(imSessionList, ImAdProvider().getAdList(ImAdManager.kPosListFeedNative));
}
} catch (e) {
debugLog(e);
}
imSessionList ??= [];
update();
}
void _listerIMConversation() {
debugLog("_listerIMConversation 变化");
unawaited(_loadData());
}
IMSessionModel? fetchSameSession(IMSessionModel info) {
for (IMSessionModel model in (imSessionList ?? [])) {
if (model.imUserId == info.imUserId) {
return model;
}
}
return null;
}
void skipToChatDetail(IMSessionModel sessionInfo) async {
await jumpToChatDetail(sessionInfo);
IMSessionModel? model = fetchSameSession(sessionInfo);
if (model != null) {
model.unreadCount = 0;
update();
_markConversationRead(sessionId: model.imUserId ?? 0);
}
}
// 发送会话通知,消息已读,取消会话红点
Future<void> _markConversationRead({required int sessionId}) async {
try {
await ImCore.setConversationRead("${sessionId}_0");
} catch (e) {
debugLog('iM- read1: $e');
}
}
@override
void onClose() {
ImCore.removeConversationChangedListener(_listerIMConversation);
super.onClose();
}
}
@@ -1,135 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:jydj/tools_base/loading/loading_center_widget.dart';
import 'package:jydj/tools_base/widget/net_image_widget.dart';
import 'package:jydj/im_manager/chat_detail_page/Im_ad_view.dart';
import 'package:jydj/hj_utils/screen.dart';
import '../im_ad/im_ad_manager.dart';
import '../model/im_message_resp.dart';
import 'chat_detail_logic.dart';
import 'chat_session_list_logic.dart';
class ChatSessionListView extends StatelessWidget {
final bool isSliverStyle;
const ChatSessionListView({
this.isSliverStyle = true,
super.key,
});
@override
Widget build(BuildContext context) {
return GetBuilder<ChatSessionListLogic>(
init: ChatSessionListLogic(),
builder: (logic) {
if (logic.imSessionList == null) {
return SliverToBoxAdapter(
child: SizedBox(
height: 300,
child: LoadingCenterWidget(),
),
);
} else if (logic.imStatus == IMConnectStatus.fail) {
return SliverToBoxAdapter(
child: CErrorWidget(
errorMsg: "消息连接失败",
retryOnTap: () => logic.onInitData(),
),
);
} else if (logic.imSessionList!.isEmpty) {
return SliverToBoxAdapter(
child: SizedBox(
height: 300,
child: CErrorWidget(),
),
);
} else {
return SliverList.builder(
itemCount: logic.imSessionList?.length ?? 0,
itemBuilder: (context, index) {
IMSessionModel model = logic.imSessionList![index];
if (model.isAdStyle) {
return ImAdView(
position: ImAdManager.kPosListFeedNative,
adItem: model.adItem,
);
} else {
return _buildItemInfo(logic, model);
}
},
);
}
},
);
}
Widget _buildItemInfo(ChatSessionListLogic logic, IMSessionModel sessionInfo) {
return GestureDetector(
onTap: () => logic.skipToChatDetail(sessionInfo),
child: Container(
height: 68,
color: Colors.transparent,
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
NetworkImageLoader(
imageUrl: sessionInfo.avatar ?? "",
width: 48,
height: 48,
borderRadius: 48,
),
12.sizeBoxW,
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
sessionInfo.nickName ?? "",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
if (sessionInfo.hasUnRead)
Container(
width: 10,
height: 10,
margin: EdgeInsets.only(bottom: 6, left: 4),
decoration: BoxDecoration(
color: Color(0xffF52C56),
borderRadius: BorderRadius.circular(10),
),
),
Spacer(),
Text(
sessionInfo.updatedYMD ?? "",
style: TextStyle(
fontSize: 12,
color:Color(0xff525252),
),
),
],
),
Text(
sessionInfo.messageType == 2 ? "[图片]" : sessionInfo.lastText ?? "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14,
color: Color(0xff525252),
),
),
],
),
),
],
),
),
);
}
}
@@ -1,234 +0,0 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:jydj/tools_base/global_store/store.dart';
import 'package:jydj/tools_base/widget/net_image_widget.dart';
import 'package:jydj/tools_base/widget/image_browser_page.dart';
import 'package:jydj/routers/jump_router.dart';
import '../../model/im_message_resp.dart';
class ChatItemCell extends StatefulWidget {
final IMMessageModel? model;
final VoidCallback? onResend;
final int? peerUid;
ChatItemCell({Key? key, this.model, this.onResend, this.peerUid}) : super(key: key);
@override
State<StatefulWidget> createState() {
return _ChatItemCellState();
}
}
class _ChatItemCellState extends State<ChatItemCell> {
bool get isImg => widget.model?.image?.isNotEmpty == true;
bool get isMe => widget.model?.fromSelf == true;
double imageWidth = 173;
void _showImageScan(String imageUrl) {
if (imageUrl.isNotEmpty) {
ImageBrowserPage.open([imageUrl]);
}
}
@override
Widget build(BuildContext context) {
if (isMe) {
return _buildRightStyle();
} else {
return _buildLeftStyle();
}
}
Widget _buildLeftStyle() {
return Container(
padding: EdgeInsets.fromLTRB(0, 12, 0, 6),
child: Column(
children: [
Container(
padding: EdgeInsets.fromLTRB(16, 0, 50, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () => _openProfile(widget.peerUid),
child: SizedBox(
width: 40,
height: 40,
child: NetworkImageLoader(
imageUrl: widget.model?.receiverAvatar ?? "",
width: 40,
height: 40,
borderRadius: 20,
),
),
),
SizedBox(width: 12),
Flexible(
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isImg)
InkWell(
onTap: () {
_showImageScan(widget.model?.image ?? "");
},
child: Container(
margin: EdgeInsets.only(top: 5),
alignment: Alignment.centerLeft,
constraints: BoxConstraints(maxWidth: 173),
child: _buildImageWidget(isLeft: true),
),
)
else
Container(
padding: EdgeInsets.symmetric(horizontal: 14, vertical: 10),
margin: EdgeInsets.only(top: 5),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .05),
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(12),
bottomRight: Radius.circular(12),
topRight: Radius.circular(12),
topLeft: Radius.circular(2)),
),
child: Text(
widget.model?.content ?? "",
style: TextStyle(
color: Colors.white,
fontSize: 12,
),
),
),
],
),
),
],
),
),
],
),
);
}
Widget _buildRightStyle() {
return Container(
padding: EdgeInsets.fromLTRB(0, 12, 0, 6),
child: Column(
children: [
Container(
padding: EdgeInsets.fromLTRB(50, 0, 16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.end,
children: [
_buildSendStatus(),
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (isImg)
InkWell(
onTap: () {
_showImageScan(widget.model?.image ?? "");
},
child: Container(
margin: EdgeInsets.only(top: 5),
alignment: Alignment.centerRight,
constraints: BoxConstraints(maxWidth: 173),
child: _buildImageWidget(isLeft: false),
),
)
else
Container(
padding: EdgeInsets.symmetric(horizontal: 14, vertical: 10),
margin: EdgeInsets.only(top: 5),
decoration: BoxDecoration(
color: Color(0xff1DC194),
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(12),
bottomRight: Radius.circular(12),
topLeft: Radius.circular(12),
topRight: Radius.circular(4)),
),
child: Text(
widget.model?.content ?? "",
style: TextStyle(
color: Color(0xFF000000),
fontSize: 12,
),
),
),
],
),
),
SizedBox(width: 12),
GestureDetector(
onTap: () => _openProfile(globalStore.meInfo?.uid),
child: SizedBox(
width: 40,
height: 40,
child: NetworkImageLoader(
imageUrl: widget.model?.senderAvatar ?? "",
width: 40,
height: 40,
borderRadius: 20,
),
),
),
],
),
),
],
),
);
}
// 发送状态指示: 0发送中(菊花) 1失败(红色叹号,点击重发) 其余正常不展示
Widget _buildSendStatus() {
final state = widget.model?.messageState;
if (state == 0) {
return Padding(
padding: EdgeInsets.only(right: 8),
child: CupertinoActivityIndicator(radius: 8, color: Color(0xff999999)),
);
}
if (state == 1) {
return GestureDetector(
onTap: widget.onResend,
child: Padding(
padding: EdgeInsets.only(right: 8),
child: Icon(Icons.error, color: Color(0xffFF004F), size: 22),
),
);
}
return const SizedBox.shrink();
}
void _openProfile(int? uid) {
if (uid != null && uid > 0) {
pushToPersonCenter(uid);
}
}
Widget _buildImageWidget({bool isLeft = false}) {
return Stack(
children: [
NetworkImageLoader(
imageUrl: widget.model?.image ?? "",
width: imageWidth,
imgBorderRadius: BorderRadius.only(
bottomLeft: Radius.circular(12),
bottomRight: Radius.circular(12),
topRight: Radius.circular(isLeft ? 12 : 0),
topLeft: Radius.circular(isLeft ? 0 : 12),
),
),
],
);
}
}
@@ -1,157 +0,0 @@
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:jydj/assets_tool/images.dart';
import 'package:jydj/tools_base/widget/group_text_filed.dart';
import 'package:jydj/hj_utils/screen.dart';
import 'package:image_pickers/image_pickers.dart';
class InputFootView extends StatefulWidget {
final Function(bool) begainCommentHandler; // true 发送
final bool isSendStatus;
final TextEditingController textEditController;
final String? imagePath;
final Function(String?)? imgCallback;
final Function(String path)? onImageSend; // 选图即发:选完直接发送,不走底部预览
final String? placeholder;
const InputFootView({
super.key,
required this.begainCommentHandler,
this.isSendStatus = false,
required this.textEditController,
this.imagePath,
this.imgCallback,
this.onImageSend,
this.placeholder,
});
@override
State<InputFootView> createState() => _InputFootViewState();
}
class _InputFootViewState extends State<InputFootView> {
bool isImageSelectIng = false;
void choseImg() async {
isImageSelectIng = true;
final listMedia = await ImagePickers.pickerPaths(
uiConfig: UIConfig(uiThemeColor: Colors.white),
galleryMode: GalleryMode.image,
selectCount: 1,
showCamera: false,
);
if (listMedia.isNotEmpty) {
final ret = listMedia.map((e) => e.path ?? '').toList();
String selectedImgPath = ret.first;
if (selectedImgPath.isNotEmpty) {
// 选完图直接发送,不在底部预览
widget.onImageSend?.call(selectedImgPath);
}
}
setState(() {});
Future.delayed(Duration(milliseconds: 500), () {
isImageSelectIng = false;
});
}
@override
Widget build(BuildContext context) {
return SafeArea(
bottom: true,
child: Container(
color: const Color(0xff000000),
child: Container(
height: 58,
width: double.infinity,
margin: EdgeInsets.symmetric(vertical: 12.h, horizontal: 18.w),
child: Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => widget.begainCommentHandler(false),
child: GroupTextFiled(
controller: widget.textEditController,
height: 32,
enabled: false,
padding: const EdgeInsets.symmetric(horizontal: 20),
textStyle: TextStyle(color: Colors.white, fontSize: 14.sp),
placeholder: widget.placeholder ?? '请输入消息',
placeholderTextStyle:
const TextStyle(color: Color(0xFF525252), fontSize: 12, fontWeight: FontWeight.w400),
decoration: BoxDecoration(
color: const Color(0x1AFFFFFF),
borderRadius: BorderRadius.circular(90),
),
),
),
),
Container(
margin: const EdgeInsets.only(left: 12),
child: Stack(
children: [
SizedBox(
height: 24,
width: 24,
child: GestureDetector(
onTap: choseImg,
child: widget.imagePath?.isNotEmpty == true
? ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.file(
File(widget.imagePath!),
width: 24,
height: 24,
fit: BoxFit.cover,
),
)
: Image.asset("icon_pic.webp".commentPath),
),
),
if (widget.imagePath?.isNotEmpty == true)
Positioned(
top: 0,
right: 0,
child: GestureDetector(
onTap: () {
widget.imgCallback?.call(null);
setState(() {});
},
child: Image.asset(
"close_grey.png".commonImgPath,
width: 12,
height: 12,
),
),
),
],
),
),
10.sizeBoxW,
_buildSendButton(),
],
),
),
),
);
}
Widget _buildSendButton() {
return GestureDetector(
onTap: () {
widget.begainCommentHandler(true);
},
child: SizedBox(
width: 28,
height: 28,
child: widget.isSendStatus
? const CupertinoActivityIndicator(
color: Colors.redAccent,
radius: 12,
)
: Image.asset('ic_send.webp'.commentPath, width: 28, height: 28),
),
);
}
}
@@ -1,166 +0,0 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:jydj/assets_tool/images.dart';
import 'package:jydj/tools_base/widget/group_text_filed.dart';
import 'package:jydj/hj_utils/screen.dart';
import 'package:image_pickers/image_pickers.dart';
class InputSheetView extends StatefulWidget {
final String? hint;
final TextEditingController textEditController;
final String? selectedImgPath;
final Function(String? imgPath)? imgCallback;
const InputSheetView({
super.key,
this.hint,
required this.textEditController,
this.selectedImgPath,
this.imgCallback,
});
@override
State<InputSheetView> createState() => _InputSheetViewState();
}
class _InputSheetViewState extends State<InputSheetView> with WidgetsBindingObserver {
final focus = FocusNode();
TextEditingController get textEditController => widget.textEditController;
bool hasShowKeyboard = false;
bool isImageSelectIng = false;
String? selectedImgPath;
@override
void initState() {
super.initState();
selectedImgPath = widget.selectedImgPath;
textEditController.addListener(() {
if (mounted) setState(() {});
});
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
focus.requestFocus();
});
WidgetsBinding.instance.addObserver(this);
}
void choseImg() async {
isImageSelectIng = true;
final listMedia = await ImagePickers.pickerPaths(
uiConfig: UIConfig(uiThemeColor: Colors.white),
galleryMode: GalleryMode.image,
selectCount: 1,
showCamera: false,
);
if (listMedia.isNotEmpty) {
final ret = listMedia.map((e) => e.path ?? '').toList();
selectedImgPath = ret.first;
widget.imgCallback?.call(selectedImgPath!);
}
setState(() {});
Future.delayed(Duration(milliseconds: 500), () {
isImageSelectIng = false;
});
}
@override
Widget build(BuildContext context) {
// 固定输入栏高度,避免 bottomSheet 占满屏幕产生蒙层
return Material(
color: const Color(0xff000000),
child: Container(
height: 58,
width: double.infinity,
margin: EdgeInsets.symmetric(vertical: 12.h, horizontal: 18.w),
child: Row(
children: [
Expanded(
child: GestureDetector(
onTap: () {
focus.requestFocus();
},
child: GroupTextFiled(
focusNode: focus,
controller: widget.textEditController,
height: 32,
padding: const EdgeInsets.symmetric(horizontal: 20),
textStyle: TextStyle(color: Colors.white, fontSize: 14.sp),
placeholder: widget.hint ?? '请输入消息',
placeholderTextStyle:
const TextStyle(color: Color(0xFF525252), fontSize: 12, fontWeight: FontWeight.w400),
decoration: BoxDecoration(
color: const Color(0x1AFFFFFF),
borderRadius: BorderRadius.circular(90),
),
),
),
),
Container(
margin: const EdgeInsets.only(left: 12),
child: Stack(
children: [
SizedBox(
height: 24,
width: 24,
child: GestureDetector(
onTap: choseImg,
child: selectedImgPath?.isNotEmpty == true
? ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.file(
File(selectedImgPath!),
width: 24,
height: 24,
fit: BoxFit.cover,
),
)
: Image.asset("icon_pic.webp".commentPath),
),
),
if (selectedImgPath?.isNotEmpty == true)
Positioned(
top: 0,
right: 0,
child: GestureDetector(
onTap: () {
widget.imgCallback?.call(null);
setState(() {});
},
child: Image.asset(
"close_grey.png".commonImgPath,
width: 12,
height: 12,
),
),
),
],
),
),
10.sizeBoxW,
_buildSendButton(),
],
),
),
);
}
Widget _buildSendButton() {
return GestureDetector(
onTap: () {
focus.unfocus();
Get.back(result: true);
},
child: Image.asset('ic_send.webp'.commentPath, width: 28, height: 28),
);
}
@override
void dispose() {
focus.unfocus();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
}
-111
View File
@@ -1,111 +0,0 @@
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
@@ -1,155 +0,0 @@
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
@@ -1,101 +0,0 @@
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();
}
}
-278
View File
@@ -1,278 +0,0 @@
import 'dart:async';
import 'package:flutter_mm_core/im_core.dart';
import 'package:get/get.dart';
import 'package:jydj/tools_base/debug_log.dart';
import 'package:jydj/tools_base/global_store/store.dart';
import 'package:jydj/tools_base/loading/loading_alert_widget.dart';
import 'package:jydj/tools_base/toast.dart';
import 'api/im_api_service.dart';
import 'chat_detail_page/chat_detail_page.dart';
import 'im_ad/im_ad_manager.dart';
import 'im_notification.dart';
import 'model/im_message_resp.dart';
import 'model/im_user_model.dart';
class ImManager {
// 单例
static final ImManager _ins = ImManager._();
factory ImManager() => _ins;
String socketURL = '';
String dynamicConfigDomain = '';
bool _isConnectSuccess = false; // true 连接成功
bool _isConnectIng = false; //true: 正在连接中
Future<bool>? _connectServerFuture;
Future<bool>? _refreshTokenFuture;
ImManager._();
String get _imToken => globalStore.meInfo?.imToken ?? "";
int? get imUserId => globalStore.meInfo?.imUserId;
bool _initFinish = false;
void initImSDK() {
if (socketURL.isEmpty || dynamicConfigDomain.isEmpty) {
debugLog("IM-initImSDK 参数配置有问题");
return;
}
if (_initFinish) return;
_initFinish = true;
ImCore.initSDK(
appKey: '',
appSecret: '',
socketURL: socketURL,
dynamicConfigDomain: dynamicConfigDomain,
);
ImCore.configureLog(
showGeneralLog: true,
showHttpLog: true,
showWebSocketLog: true,
showHeartbeatLog: false,
showDatabaseLog: true,
showMediaLog: true,
);
debugLog('IM-SDK 初始化完成');
_addChatListener();
}
// token失效或者断开连接,需要重新连接
Future<bool> connectServer() {
if (_isConnectSuccess) return Future.value(true);
if (_refreshTokenFuture != null) return _refreshTokenFuture!;
if (_connectServerFuture != null) return _connectServerFuture!;
_connectServerFuture = _connectServer().whenComplete(() {
_connectServerFuture = null;
});
return _connectServerFuture!;
}
Future<bool> _connectServer() async {
try {
if (_refreshTokenFuture != null) return _refreshTokenFuture!;
if (_isConnectSuccess) return true;
if (_isConnectIng) return _isConnectSuccess;
_isConnectIng = true;
_isConnectSuccess = false;
if (imUserId == null) {
debugLog('imUserId 为空');
ImUserModel? model = await IMApiService.getImUserInfo(uid: globalStore.meInfo?.uid);
globalStore.meInfo?.imUserId = int.tryParse(model?.imUserId ?? "");
}
if (_imToken.isEmpty) {
debugLog('IM- IM-token 获取失败');
} else {
bool ok = await _loginWithToken(_imToken);
if (ok) {
await ImAdManager.pullImAdsOnce();
_onIMConversation();
}
}
} catch (e) {
debugLog('IM-登录失败:$e');
} finally {
_isConnectIng = false;
}
return _isConnectSuccess;
}
Future<bool> _loginWithToken(String token) async {
final ok = await ImCore.linkImRequest(token: token).timeout(const Duration(seconds: 15));
if (!ok) {
debugLog('IM-登录失败');
return false;
}
await ImCore.whenReady.timeout(const Duration(seconds: 15));
_isConnectSuccess = true;
debugLog('IM-登录成功');
return true;
}
Future<bool> _refreshTokenAndReconnect() {
if (_refreshTokenFuture != null) return _refreshTokenFuture!;
_refreshTokenFuture = _refreshTokenAndReconnectInternal().whenComplete(() {
_refreshTokenFuture = null;
});
return _refreshTokenFuture!;
}
Future<bool> _refreshTokenAndReconnectInternal() async {
try {
_isConnectSuccess = false;
debugLog('IM-token 已失效,开始重新获取');
ImTokenInfo? model = await IMApiService.getImToken();
final token = model?.imToken ?? '';
if (token.isEmpty) {
debugLog('IM-token 重新获取失败');
return false;
}
globalStore.meInfo?.imToken = token;
if (model?.imUserId != null) {
globalStore.meInfo?.imUserId = model?.imUserId;
}
final ok = await _loginWithToken(token);
debugLog(ok ? 'IM-token 刷新并重连成功' : 'IM-token 刷新后重连失败');
return ok;
} catch (e) {
debugLog('IM-token 刷新并重连失败:$e');
return false;
}
}
void signOut() async {
try {
_isConnectSuccess = false;
await ImCore.loginOutRequest();
debugLog('IM-退出成功');
} catch (e) {
debugLog('IM-退出失败:$e');
}
}
/// 账号切换/重新登录:先登出旧账号 IM(重置当前用户运行态与本地会话),
/// 再用新账号(globalStore.meInfo 已更新)重新连接,避免会话列表残留旧数据。
Future<void> reloginOnAccountSwitch() async {
try {
_isConnectSuccess = false;
_isConnectIng = false;
_connectServerFuture = null;
_refreshTokenFuture = null;
await ImCore.loginOutRequest();
debugLog('IM-切换账号已登出旧会话');
} catch (e) {
debugLog('IM-切换账号登出失败:$e');
}
await connectServer();
}
void _addChatListener() {
removeAllListener();
ImCore.addRecvNewMessageListener(_onMessageReceived);
ImCore.addSendFailedListener(_onSendFailed);
ImCore.addMessageAckFailedListener(_onMessageAckFailed);
ImCore.addConversationChangedListener(_onIMConversation);
ImCore.addTokenExpiredListener(_onTokenExpired);
ImCore.onTokenExpired = _onTokenExpired;
}
void _onIMConversation() async {
ModelConversationListResult sessionRet = await ImCore.getConversations(includeArchivedCount: false);
ImMsgProvider().setUnReadStatus(sessionRet);
}
void _onTokenExpired() {
unawaited(_refreshTokenAndReconnect());
}
void _onMessageReceived(ModelMessage message) {
debugLog('IM-消息收到:${message.content},来自:${message.senderID}');
if (message.isPassthrough) {
// 全局透传消息(chatMessageType=400)监听:用于接收后端 AD_NOTIFY 等业务信号。
// 透传不入库、不计未读、不进会话;只在线推。
// 解析 / 去重 / 拉广告逻辑统一在 ImAdManager 里。
ImAdManager.onRecv(message);
} else {
imEventBus.emit(IMMsgEvent(message, true));
}
}
void _onSendFailed(ImCoreException exception, ModelMessage? message) {
imEventBus.emit(IMMsgEvent(message, false));
debugLog('IM-消息发送失败:${exception.message}');
if (exception.message.contains("当前 IM 未连接")) {
_isConnectSuccess = false;
connectServer();
}
}
void _onMessageAckFailed(ImCoreException exception, ModelMessage? message) {
debugLog('IM-消息 ACK 失败:${exception.message}');
_isConnectSuccess = false;
connectServer();
}
Future<bool> sendMessage(ModelCustomMessage message) async {
try {
final ok = await ImCore.sendMessage(msg: message);
if (ok) {
debugLog('已请求发送消息');
return true;
} else {
debugLog('发送消息失败');
return false;
}
} catch (e) {
debugLog('发送消息失败:$e');
return false;
}
}
void removeAllListener() {
ImCore.removeRecvNewMessageListener(_onMessageReceived);
ImCore.removeSendFailedListener(_onSendFailed);
ImCore.removeMessageAckFailedListener(_onMessageAckFailed);
ImCore.removeConversationChangedListener(_onIMConversation);
ImCore.removeTokenExpiredListener(_onTokenExpired);
}
}
Future jumpToChatDetail(IMSessionModel model) async {
// 优先用 Session.imUserId(SDK conversation.targetId,已经是 IM userId)
// 没有再回退到 ensureFriend 转换
int peerImUserId = model.imUserId ?? 0;
if (peerImUserId <= 0) {
final peerId = model.peerId ?? 0;
if (peerId <= 0) return;
try {
LoadingAlertWidget.show();
final result = await IMApiService.ensureFriend(peerId);
peerImUserId = int.tryParse(result.peerImUserId ?? '') ?? 0;
LoadingAlertWidget.cancel();
} catch (_) {
LoadingAlertWidget.cancel();
showToast('无法打开聊天,请稍后再试');
return;
}
}
if (peerImUserId <= 0) {
showToast('无法打开聊天,请稍后再试');
return;
}
return Get.to(
ChatDetailPage(
imUserId: peerImUserId,
userName: model.nickName,
uid: model.peerId,
peerAvatar: model.avatar,
),
preventDuplicates: false,
);
}
-99
View File
@@ -1,99 +0,0 @@
import 'dart:async';
import 'package:event_bus/event_bus.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_mm_core/im_core/model/model_conversation.dart';
import 'package:flutter_mm_core/im_core/model/model_message.dart';
import 'im_ad/im_ad_item.dart';
import 'im_ad/im_ad_manager.dart';
import 'im_ad/im_ad_provider.dart';
import 'model/im_message_resp.dart';
typedef EventCallback<T> = void Function(T event);
class ImMsgProvider with ChangeNotifier {
static final ImMsgProvider _ins = ImMsgProvider._();
factory ImMsgProvider() => _ins;
ImMsgProvider._();
int _unReadCount = 0;
bool get hasUnreadCount => _unReadCount > 0;
ModelConversationListResult? _curConversation;
void setUnReadStatus(ModelConversationListResult resp) {
_curConversation = resp;
_unReadCount = resp.totalUnreadCount;
int adUnReadCount = 0;
int adMaxCount = resp.conversations.length ~/ 3 + 1;
List<ImAdItem> adList = ImAdProvider().getAdList(ImAdManager.kPosListFeedNative);
for (int i = 0; i < adMaxCount && i < adList.length; i++) {
ImAdItem adItem = adList[i];
if (ImAdProvider().isUnreadAd(adItem.id)) {
adUnReadCount++;
}
}
_unReadCount += adUnReadCount;
notifyListeners();
}
//广告数据刷新,我的消息红点状态也要同步更新
void unReadStatusUpdateByAds() {
setUnReadStatus(_curConversation ?? ModelConversationListResult());
}
void setUnReadStatusFromAd() {
setUnReadStatus(_curConversation ?? ModelConversationListResult());
}
}
class IMMsgEvent {
final ModelMessage? model;
final bool isSuccess;
IMMessageModel? get msgModel {
if (model == null) return null;
IMMessageModel convtModel = IMMessageModel.convertModel(model!);
return convtModel;
}
IMMsgEvent(this.model, this.isSuccess);
}
class ImEventBus {
//保存单例
static final ImEventBus _instance = ImEventBus._internal();
//工厂构造函数
factory ImEventBus() => _instance;
//初始化eventBus
late EventBus _eventBus;
ImEventBus._internal() {
// 初始化
_eventBus = EventBus();
}
/// 开启eventbus订阅
StreamSubscription on<T>(EventCallback<T> callback) {
StreamSubscription stream = _eventBus.on<T>().listen((event) {
callback(event);
});
return stream;
}
/// 发送消息
void emit(event) {
_eventBus.fire(event);
}
/// 移除stream
void off(StreamSubscription steam) {
steam.cancel();
}
}
var imEventBus = ImEventBus._instance;
-203
View File
@@ -1,203 +0,0 @@
import 'package:flutter_mm_core/im_core/model/model_conversation.dart';
import 'package:flutter_mm_core/im_core/model/model_message.dart';
import 'package:flutter_mm_core/im_core/model/model_user_info.dart';
import 'package:jydj/tools_base/global_store/store.dart';
import '../im_ad/im_ad_item.dart';
/// 会话
class IMSessionModel {
int? peerId;
String? avatar;
String? nickName;
String? lastText;
String? updatedAt;
int? messageType;
int seq = 0;
// 对方在线状态,来自 IM SDK 的 ModelConversation.onlineInfo.online
bool? online;
// SDK 内部会话主键(${otherId}_${isGroup}),用于 setConversationRead 等 API
String? primaryId;
// SDK 的 IM userId(区分业务 peerId);私信详情用它做精确路由
int? imUserId;
int? unreadCount; // 未读数
ImAdItem? adItem;
bool get isAdStyle => adItem != null;
bool get hasUnRead => (unreadCount ?? 0) > 0;
String? get updatedYMD {
if (updatedAt?.split(" ").isNotEmpty == true) {
return updatedAt?.split(" ").first;
}
if (updatedAt?.split("T").isNotEmpty == true) {
return updatedAt?.split("T").first;
}
return updatedAt;
}
ModelConversation? originModel;
IMSessionModel({this.peerId, this.nickName, this.avatar});
static List<IMSessionModel> covertData(ModelConversationListResult conList) {
List<IMSessionModel> sessionList = [];
for (int i = 0; i < (conList.conversations.length); i++) {
ModelConversation conversation = conList.conversations[i];
ModelUserInfo? userInfo = conversation.userInfo;
IMSessionModel sessionInfo = IMSessionModel();
sessionInfo.peerId = userInfo?.id ?? int.tryParse(conversation.targetId ?? '');
sessionInfo.avatar = userInfo?.imgUrl;
sessionInfo.nickName = userInfo?.name;
sessionInfo.lastText = conversation.content;
sessionInfo.online = conversation.onlineInfo?.online;
sessionInfo.primaryId = conversation.primaryId;
sessionInfo.unreadCount = conversation.unreadCount;
sessionInfo.messageType = conversation.messageType;
sessionInfo.imUserId = int.tryParse(conversation.targetId ?? '');
sessionInfo.seq = conversation.seq;
sessionInfo.originModel = conversation;
if (conversation.time > 0) {
var dateTimeStr = _conversationTimeToIsoString(conversation.time);
sessionInfo.updatedAt = dateTimeStr;
}
sessionList.add(sessionInfo);
}
return sessionList;
}
static String _conversationTimeToIsoString(int rawTime) {
if (rawTime <= 0) return '';
final milliseconds = rawTime >= 1000000000000 ? rawTime : rawTime * 1000;
return DateTime.fromMillisecondsSinceEpoch(milliseconds).toString();
}
}
class IMMessageResp {
bool? enabled;
int? userId;
int? imUserId;
int? peerId;
int? peerImUserId;
List<IMMessageModel>? messages;
IMMessageResp();
IMMessageResp.fromJson(Map<String, dynamic> json) {
enabled = json['enabled'];
userId = json['userId'];
imUserId = json['imUserId'];
peerId = json['peerId'];
peerImUserId = json['peerImUserId'];
if (json['messages'] is List) {
messages = [];
json['messages'].forEach((v) {
messages!.add(IMMessageModel.fromJson(v));
});
}
}
}
class IMMessageModel {
String? messageId;
String? senderId;
String? receiverId;
String? content;
dynamic messageType;
int? messageState; // 发送状态 0发送中 1失败 2成功 (MessageSendStatus)
String? localId; // 客户端本地ID,发送中/失败状态更新用
int? seq;
int? createdAt;
int? timestamp;
bool? fromSelf;
String? senderNickName;
String? senderAvatar;
String? receiverNickName;
String? receiverAvatar;
/// 业务扩展字段,服务端透传原始字符串;内容不要求是 JSON。
String? extra;
String? url; //文件地址
String? localPath; //下载后的本地路径
ModelMessage? originMsgModel;
IMMessageModel();
IMMessageModel.fromJson(Map<String, dynamic> json) {
receiverAvatar = json['receiverAvatar'];
receiverNickName = json['receiverNickName'];
senderAvatar = json['senderAvatar'];
senderNickName = json['senderNickName'];
messageId = json['messageId'];
senderId = json['senderId']?.toString();
receiverId = json['receiverId']?.toString();
content = json['content'];
messageType = json['messageType'];
seq = json['seq'];
createdAt = json['createdAt'];
fromSelf = json['fromSelf'];
}
/// 图片消息有两种:
/// - chatMessageType=2 (SDK 原生 IMAGE):用 m.url / m.localPath(完整 CDN 链接)
/// - chatMessageType=100 (CUSTOMIZED):双层解析 m.extra 拿 url
static IMMessageModel convertModel(ModelMessage model) {
IMMessageModel msgModel = IMMessageModel();
msgModel.originMsgModel = model;
msgModel.messageId = model.messageId;
msgModel.content = model.content;
msgModel.senderId = model.senderID;
msgModel.receiverId = model.receiverID;
msgModel.createdAt = model.timestamp;
msgModel.timestamp = model.timestamp;
msgModel.seq = model.sequence;
msgModel.messageType = model.chatMessageType;
msgModel.messageState = model.messageState;
msgModel.localId = model.localId;
if (model.senderID == globalStore.meInfo?.imUserId?.toString()) {
msgModel.fromSelf = true;
}
final type = model.chatMessageType ?? 0;
if (type == 2) {
// SDK 原生 IMAGE
if ((model.url ?? '').isNotEmpty) {
msgModel.url = model.url;
} else if ((model.localPath ?? '').isNotEmpty) {
msgModel.localPath = model.localPath;
}
}
msgModel.extra = model.extra;
return msgModel;
}
String? get image {
if (url?.isNotEmpty == true) {
return url;
}
if (localPath?.isNotEmpty == true) {
return localPath;
}
return null;
}
}
class IMSendFileInfo {
double? height;
int? type; // 1 图片, 2文件
String? url;
IMSendFileInfo({this.height, this.type, this.url});
IMSendFileInfo.fromJson(Map<String, dynamic> json) {
height = json['height'];
type = json['type'];
url = json['url'] ?? '';
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['height'] = height;
data['type'] = type;
data['url'] = url;
return data;
}
}
-74
View File
@@ -1,74 +0,0 @@
class ImUserModel {
bool? enabled;
int? userId;
String? imUserId;
String? imToken;
int? peerId; // 好友 id
String? peerImUserId; // 好友 IM id
bool? friendAdded; // true:添加好友成功
ImUserModel({this.enabled, this.userId, this.imUserId, this.imToken});
ImUserModel.fromJson(Map<String, dynamic> json) {
enabled = json['enabled'];
userId = json['userId'];
imUserId = json['imUserId']?.toString();
imToken = json['imToken']?.toString() ?? json['token']?.toString();
peerId = json['peerId'];
peerImUserId = json['peerImUserId']?.toString();
friendAdded = json['friendAdded'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['imUserId'] = imUserId;
data['imToken'] = imToken;
data['userId'] = userId;
data['enabled'] = enabled;
return data;
}
}
class ImTokenInfo {
bool? enabled;
String? imToken;
String? token;
int? userId;
int? imUserId;
bool? registered;
String? dynamicConfigDomain;
String? socketURL;
String? sysType;
String? os;
String? osType;
ImTokenInfo.fromJson(Map<String, dynamic> json) {
enabled = json['enabled'];
imToken = json['imToken'];
userId = json['userId'];
imUserId = json['imUserId'];
registered = json['registered'];
dynamicConfigDomain = json['dynamicConfigDomain'];
socketURL = json['socketURL'];
sysType = json['sysType'];
os = json['os'];
osType = json['osType'];
}
}
class ImUserWallet {
int? price;
int? deductAmount;
int? deductIncome;
int? amount;
int? income;
int? balance;
ImUserWallet.fromJson(Map<String, dynamic> json) {
price = json['price'];
deductAmount = json['deductAmount'];
deductIncome = json['deductIncome'];
amount = json['amount'];
income = json['income'];
balance = json['balance'];
}
}
-445
View File
@@ -1,445 +0,0 @@
import 'package:analytics_sdk/analytics_sdk.dart';
import 'package:analytics_sdk/manager/page_name_manager.dart';
import 'package:analytics_sdk/entity/ad_click_event.dart';
import 'package:analytics_sdk/entity/ad_impression_event.dart';
import 'package:analytics_sdk/entity/comic_event.dart';
import 'package:analytics_sdk/entity/keyword_click_event.dart';
import 'package:analytics_sdk/entity/keyword_search_event.dart';
import 'package:analytics_sdk/entity/novel_event.dart';
import 'package:analytics_sdk/entity/video_event.dart';
import 'package:analytics_sdk/enum/click_item_type_enum.dart';
import 'package:analytics_sdk/enum/read_behavior_enum.dart';
import 'package:analytics_sdk/enum/video_content_type_enum.dart';
import 'package:analytics_sdk/enum/video_event_enum.dart';
import 'package:get/get.dart';
import 'package:jydj/tools_base/debug_log.dart';
import 'package:video_player/video_player.dart';
import '../hj_model/cartoon_media_info.dart';
import '../hj_model/media_content.dart';
import '../hj_model/splash/ads_model.dart';
import '../hj_model/video_model.dart';
//页面枚举
enum TrackEventPageType {
start('start', '启动页'), // 启动页广告
home('home', '首页'), // 首页公告弹窗
acgHome('acg_home', 'acg首页'), // 首页acg
live('live', '直播列表'), // 直播顶部广告
liveDetail('live_detail', '直播详情页'), // 直播详情页广告
commPost('comm_post', '社区首页'), // 顶部公共广告
commImg('comm_img', '社区图集'), // 顶部公共广告
commNovel('comm_novel', '社区小说'), // 顶部公共广告
commGroup('comm_group', '社区群聊'), // 顶部公共广告
darkweb('darkweb', '暗网'), // 直播顶部广告
mine('mine', '我的'), // 我的页面广告
search('search', '搜索页'), // 搜索页广告
videoPlay('video_play', '视频播放页'), // 视频播放页广告
topicDetail('topic_detail', '专题详情页'), // 专题详情顶部广告
appList('app_list', '应用推荐'), // 应用等
postDetail('post_detail', '社区详情页'), // 应用等
imChatCenter('im_chat_center', '私信中心'), // IM 私信会话列表
imChatDetail('im_chat_detail', '私信详情'); // IM 私信聊天页
final String pagekey;
final String pageName;
const TrackEventPageType(this.pagekey, this.pageName);
}
//搜索枚举
enum TrackEventSearchType {
video('video', 'video'), // 视频
comic('comic', '漫画'), // 漫画
acg('acg', 'acg'), // acg
user('user', '用户'), // 用户
image('image', 'image'), // image
post('post', '帖子'); // 帖子
final String clickTypeKey;
final String clickTypeName;
const TrackEventSearchType(this.clickTypeKey, this.clickTypeName);
}
/// 广告埋点类型(对应 payload 的 ad_type 字段)
enum AdType {
smallIcon('small_icon'),
verticalIcon('vertical_icon'),
moreIcon('more_icon'),
bigBanner('big_banner'),
smallBanner('small_banner'),
banner('banner'),
feed('feed'),
splash('splash'),
dialog('dialog');
/// 上报字符串值
final String value;
const AdType(this.value);
/// 字符串反查枚举,找不到返回 null
static AdType? fromValue(String? value) => values.firstWhereOrNull((e) => e.value == value);
}
class AdExposureModel {
final TrackEventPageType? pageType; //页面位置信息
AdType? adType; //广告类型,banner 类组件会就地改写(如 ..adType = AdType.bigBanner)
final int? tabIndex; //tabIndex
AdExposureModel({
this.pageType,
this.adType,
this.tabIndex,
});
}
/// 广告曝光统一管理(业务级)
class AdExposureManager {
static final Set<String> _exposedKeys = {};
/// 返回 true = 允许曝光
static bool tryExpose(String key) => _exposedKeys.add(key);
}
//广告曝光,只记录一次曝光
mixin AdExposureMixin {
void adExposure({
required AdsInfoModel adInfo,
AdExposureModel? exposureModel,
}) {
final pageKey = exposureModel?.pageType?.pagekey ?? '';
final slotKey = adInfo.position?.toString() ?? '';
final adId = adInfo.id?.toString() ?? '';
// 业务唯一曝光 Key,最终裁判在这里
final exposureKey = '$pageKey|$slotKey|$adId|tab${exposureModel?.tabIndex ?? 0}';
if (!AdExposureManager.tryExpose(exposureKey)) return;
AnalyticsSdk.instance.track(AdImpressionEvent(
pageKey: pageKey,
pageName: exposureModel?.pageType?.pageName ?? '',
adSlotKey: slotKey,
adId: adId,
creativeId: adId,
adType: exposureModel?.adType?.value ?? '', // 传广告类型字符串,不传默认空字符串
));
}
}
final adClickManager = AdClickManager();
//广告点击
class AdClickManager {
// 单例
static final AdClickManager _ins = AdClickManager._();
factory AdClickManager() => _ins;
AdClickManager._();
//广告点击事件
void adClicks({
required AdsInfoModel? adInfo,
required Map<String, dynamic> payload,
TrackEventPageType? page,
}) {
if (adInfo == null) return;
try {
final adId = adInfo.id?.toString() ?? '';
AnalyticsSdk.instance.track(AdClickEvent(
pageKey: page?.pagekey ?? '',
pageName: page?.pageName ?? '',
adSlotKey: adInfo.position?.toString() ?? '',
adId: adId,
creativeId: adId,
adType: payload['ad_type'] ?? '',
));
} catch (e) {
debugLog('adClicks', e.toString());
}
}
}
final searchClickManager = SearchClickManager();
class SearchClickManager {
// 单例
static final SearchClickManager _ins = SearchClickManager._();
factory SearchClickManager() => _ins;
SearchClickManager._();
//搜索埋点
void searchKeyword({
required String? keyword, //搜索关键词
required int? count, //搜索结果数量
required String? traceId, //搜索引擎的trace_id。没有对接搜索推荐引擎的上报空字符串。
required String? searchId, //搜索引擎列表的video_id(视频),novel_id(小说),comic_id(漫画), 可视窗口有多个,使用英文逗号分隔。
}) {
AnalyticsSdk.instance.track(KeywordSearchEvent(
keyword: keyword ?? '',
searchResultCount: count ?? 0,
searchTraceId: traceId ?? '', // 未接搜索引擎时传空字符串
searchId: searchId ?? '', // 未接搜索引擎时传空字符串
));
}
//搜索点击事件
void searchClicks({
required String? keyword,
required TrackEventSearchType clickType,
required String? id,
required int? index,
required String? traceId, //搜索引擎的trace_id。没有对接搜索推荐引擎的上报空字符串。
}) {
AnalyticsSdk.instance.track(KeywordClickEvent(
keyword: keyword ?? "",
clickItemId: id ?? '',
clickItemType: ClickItemTypeEnum(clickType.clickTypeKey, clickType.clickTypeName),
clickPosition: (index ?? 0) + 1,
searchTraceId: traceId ?? '', // 未接搜索引擎时传空字符串
));
}
}
class TrackVideoManager {
//上次上报播放完成的秒级时间戳,用于防重
static int _lastComplete = 0;
//埋点字段不收空值,统一兜默认值
static String _or(String? value, String fallback) => (value?.isEmpty ?? true) ? fallback : value!;
//视频事件
static void send(
VideoEventEnum event,
VideoModel? model,
VideoPlayerController? ctr, {
bool isPreVideo = false,
}) {
if (isPreVideo) return; //预览视频的不上报
if (ctr == null) return; //播放器为空不上报
final duration = ctr.value.duration.inSeconds;
if (duration == 0) return; //时长为0不上报,避免除0异常
final position = ctr.value.position.inSeconds;
final progress = position * 100 ~/ duration;
if (event == VideoEventEnum.VIDEO_COMPLETE) {
if (progress < 90) {
//播放进度不足90%, 转为播放事件上报
event = VideoEventEnum.VIDEO_PLAY;
} else {
// 自动播放完,业务监听会多次处理, 视频播放完成事件防重
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
if (now - _lastComplete < 2) {
debugLog("video complete 重复处理");
return;
}
_lastComplete = now;
}
}
//新增SDK埋点
AnalyticsSdk.instance.track(VideoEvent(
videoId: model?.id ?? '',
videoTitle: model?.title ?? '',
videoTypeId: _or(model?.videoTypeId, 'default'),
videoTypeName: _or(model?.videoTypeName, '默认分类'),
videoTagKey: _or(model?.tags?.map((e) => e.id).join(','), 'default'),
videoTagName: _or(model?.tags?.map((e) => e.name).join(','), '默认标签'),
videoDuration: duration,
playDuration: position,
playProgress: progress,
videoBehavior: event,
videoContentType: model?.contentType == 'video' ? VideoContentTypeEnum.video : VideoContentTypeEnum.shortVideo,
recommendTraceId: '',
// 未接推荐引擎传 ''
mediaId: model?.lsjId ?? '',
));
}
//漫画行为 展示, 下一页, 上一页,读完
static void comicEvent(
ReadBehaviorEnum behavior, //行为
{
CartoonMediaInfo? model,
int? readProgress = 0, //章节百分比
int? pageNo = 1,
MediaContent? mediaContent,
}) {
AnalyticsSdk.instance.track(ComicEvent(
comicId: model?.id ?? '',
comicTitle: model?.title ?? '',
comicTypeId: _or(model?.mId, 'default'),
comicTypeName: _or(model?.moduleName, '默认分类'),
comicTagKey: _or(model?.tagIds, 'default'),
comicTagName: _or(model?.tagNames, '默认标签'),
readProgress: readProgress ?? 0,
pageNo: pageNo ?? 0,
comicBehavior: behavior,
mediaId: model?.lsjId ?? '',
chapterId: _or(mediaContent?.id, '1'),
chapterName: _or(mediaContent?.name, '第一章'),
));
}
//小说行为 展示, 下一页, 上一页,读完
static void novelEvent(
ReadBehaviorEnum behavior, {
CartoonMediaInfo? model, //小说模型
int? readProgress = 0, //章节百分比
int? pageNo = 1,
String? chapterId,
String? chapterName,
}) {
AnalyticsSdk.instance.track(NovelEvent(
novelId: model?.id ?? '',
novelTitle: model?.title ?? '',
novelTypeId: _or(model?.mId, 'default'),
novelTypeName: _or(model?.moduleName, '默认分类'),
novelTagKey: _or(model?.tagIds, 'default'),
novelTagName: _or(model?.tagNames, '默认标签'),
readProgress: readProgress ?? 0,
pageNo: pageNo ?? 0,
novelBehavior: behavior,
mediaId: model?.lsjId ?? '',
chapterId: _or(chapterId, '1'),
chapterName: _or(chapterName, '第一章'),
));
}
}
/// 页面 pageKey -> 中文名映射,app 启动时注册一次(main.dart)
/// key 就是 Get.to 自动生成的路由名(去掉斜杠后的页面类名),改类名要同步改这里
void addMappings() {
PageNameMapper.addMappings({
'AiHomePage': 'AI首页',
'AiSquarePage': 'AI广场',
'CartoonDetailPage': '漫画详情页',
'CartoonReadPage': '漫画阅读页',
'CartoonRecommendPage': '漫画推荐页',
'CartoonSectionListPage': '漫画专题更多页',
'CartoonSectionSubPage': '漫画专题列表页',
'CartoonSubGridPage': '漫画海角页',
'CartoonSubModulePage': '漫画亚模块页',
'NovelDetailPage': '小说详情页',
'TextNovelReadPage': '文字小说阅读页',
'VoiceNovelReadPage': '有声小说阅读页',
'CommunityModulePage': '社区模块页',
'CommunityTabPage': '社区Tab页',
'SourceSubModulePage': '社区资源亚模块页',
'CommunityMainPage': '社区主页',
'TagDetailSubView': '标签详情页',
'CommunityTagDetailPage': '社区标签详情页',
'CommunityImagePage': '社区图片详情页',
'PostDetailPage': '社区帖子详情页',
'ChoseTopicPage': '发布选择话题页',
'PublishPage': '帖子发布页',
'PublishRulePage': '发布规则页',
'PicsDetailPage': '色图详情页',
'HomeTabSectionPage': '首页Tab专题页',
'HomeTabSortPage': '首页Tab分类页',
'SearchACGResultPage': '搜索ACG结果页',
'SearchResultMainPage': '搜索结果主页面',
'SearchVideoResultPage': '搜索视频结果页',
'SectionAllPage': '原创达人',
'SpecialTopicsDetailPage': '视频专题详情页',
'CartoonTagPage': '漫画标签页',
'VideoTagPage': '视频标签页',
'DramaTagPage': '短剧标签页', //DramaTagDetailPage.routeName,改名要同步
'DarkHomePage': '暗网首页',
'HomeMainPage': '视频主页',
'LiveDetailPage': '直播详情页',
'LiveMainPage': '直播主页',
'LiveSubPage': '直播子页面',
'MainPage': '主页面',
'AiPaintPage': 'AI绘画页',
'AiItvPage': 'AI图生视频页',
'AINovelSubPage': 'AI小说首页',
'AiNovelDetailPage': 'AI小说详情页',
'AIChangeFacePage': 'AI换脸页',
'AIFaceChangeSubPage': 'AI换脸子页',
'AIStripSubPage': 'AI脱衣首页',
'AIGrilFriendPage': 'AI女友页',
'AiH5Page': 'AI H5页',
'AIMainRecordPage': 'AI生成记录主页',
'AiMainItvPage': 'AI图生视频主页',
'AIModDetailPage': 'AI换脸模版页',
'AiRecordPage': 'AI生成记录子页',
'AdvancePage': '高级设置页',
'ExchangeVipPage': '兑换会员页',
'IntegralExchangePage': '积分兑换页',
'OnlinePayPage': '在线支付页',
'MineCollectPage': '我的收藏页',
'MineBuyPage': '我的购买页',
'MineHistoryPage': '历史记录页',
'MineLikePage': '我的喜欢页',
'MineAccountIdentityPage': '账号凭证页',
'RecordsPage': '明细综合页',
'WithdrawalPage': '提现页',
'MessageCenterPage': '消息中心页',
'ChatDetailPage': '私信详情页',
'MineDrivePage': '加群开车页',
'MineFollowSubPage': '关注子页面',
'MineFollowingPage': '我的关注页',
'MinePublishPostPage': '创作中心',
'BankCardHomePage': '银行卡管理页',
'BindPhonePage': '绑定手机号页',
'InviteBindPage': '绑定邀请码',
'MineExchangeCodePage': '兑换码页',
'MineFindAccountPage': '找回账号',
'MinePasswordPage': '手势密码',
'MineScanLoginPage': '扫码登录',
'MineSettingPage': '设置中心',
'MineSettingProfilePage': '设置资料页',
'SettingProfilePage': '编辑资料',
'SettingAvatarPage': '设置头像',
'SettingNickNamePage': '设置昵称',
'MineSharePage': '分享邀请',
'MineShareRecordPage': '邀请记录',
'VideoCachePage': '下载缓存',
'MineChargeCoinPage': '金币充值',
'MineChargeVipPage': '会员充值',
'MineFeedbackPage': '意见反馈',
'MineHelpPage': '帮助反馈',
'MineQAPage': '常见问题',
'LouFengAdPage': '应用推荐',
'MineIncomePage': '分享邀请',
'RecommendAppPage': '推荐应用页',
'MoreFreeVideoPage': '更多免费视频',
'ShareDataListPage': '推广数据列表',
'ShareDetailsPage': '推广数据',
'WelfareHomePage': '应用推荐首页',
'SignDailyPage': '每日签到',
'HomeMinePage': '我的',
'PreSalePage': '预售详情页',
'ShortVideoMainPage': '抖音首页',
'ShortVideoPage': '抖音',
'SplashPage': '启动页',
'RankMainPage': '排行榜主页',
'RankSubPage': '排行榜子页',
'UserCenterPage': '用户首页',
'GroupChatPage': '群聊页',
'GroupChatDetailPage': '群聊详情页',
'SimpleVideoPlayerPage': '简单视频播放页',
'VideoFullPage': '全屏视频播放',
'VideoPage': '视频播放页',
'H5Page': 'H5页面',
'SearchMainPage': '搜索页',
'VideoAllTypePage': '视频片库',
'AcgGameRecommendPage': 'ACG游戏推荐页',
'AcgGameDetailPage': 'ACG游戏详情页',
'CartoonSubDetailPage': '漫画子详情页',
'MessageCenterSubPage': '消息中心子页',
'MinePublishSubPage': '创作中心子页',
'Home': '主页Tab',
'Community': '社区Tab',
'ShortVideo': '抖音Tab',
'AI': 'AI Tab',
'Mine': '我的Tab',
'Pic': ' 色图Tab',
'Novel': '小说Tab',
});
}