import 'package:flustars/flustars.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; import 'package:star_lock/appRouters.dart'; import 'package:star_lock/app_settings/app_settings.dart'; import 'package:star_lock/main/lockDetail/doorLockLog/date_time_extensions.dart'; import 'package:star_lock/main/lockDetail/doorLockLog/doorLockLog_entity.dart'; import 'package:star_lock/main/lockDetail/doorLockLog/doorLockLog_logic.dart'; import 'package:star_lock/main/lockDetail/doorLockLog/doorLockLog_state.dart'; import 'package:star_lock/main/lockDetail/doorLockLog/exportRecordDialog/exportRecordDialog_page.dart'; import 'package:star_lock/main/lockDetail/doorLockLog/week_calendar_view.dart'; import 'package:star_lock/main/lockDetail/videoLog/videoLog/videoLog_entity.dart'; import 'package:star_lock/main/lockDetail/videoLog/widget/full_screenImage_page.dart'; import 'package:star_lock/main/lockDetail/videoLog/widget/video_thumbnail_image.dart'; import 'package:star_lock/tools/EasyRefreshTool.dart'; import 'package:star_lock/tools/advancedCalendar/src/widget.dart'; import 'package:star_lock/tools/commonDataManage.dart'; import 'package:star_lock/tools/dateTool.dart'; import 'package:star_lock/tools/menuItem/xsDropDownWidget.dart'; import 'package:star_lock/tools/noData.dart'; import 'package:star_lock/tools/showCupertinoAlertView.dart'; import 'package:star_lock/tools/showTipView.dart'; import 'package:timelines/timelines.dart'; import '../../../app_settings/app_colors.dart'; import '../../../tools/appRouteObserver.dart'; import '../../../tools/titleAppBar.dart'; class DoorLockLogPage extends StatefulWidget { const DoorLockLogPage({Key? key}) : super(key: key); @override State createState() => _DoorLockLogPageState(); } class _DoorLockLogPageState extends State with RouteAware { final ScrollController _scrollController = ScrollController(); final DoorLockLogLogic logic = Get.put(DoorLockLogLogic()); final DoorLockLogState state = Get.find().state; bool _isAtBottom = false; @override void initState() { super.initState(); _scrollController.addListener(_onScroll); } void _onScroll() { final max = _scrollController.position.maxScrollExtent; final current = _scrollController.position.pixels; AppLog.log('current:${current}'); // 判断是否接近底部(例如 5 像素内) if (current >= max - 5) { if (!_isAtBottom) { setState(() { _isAtBottom = true; }); print('✅ 已滑动到 timelines 列表底部!'); // 可以在这里触发加载更多、发送事件等 } } else { if (_isAtBottom) { setState(() { _isAtBottom = false; }); } } } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.mainBackgroundColor, appBar: TitleAppBar( barTitle: '操作记录'.tr, haveBack: true, backgroundColor: AppColors.mainColor, actionsList: [ Visibility( visible: CommonDataManage().currentKeyInfo.isLockOwner == 1 || CommonDataManage().currentKeyInfo.keyRight == 1, child: GestureDetector( child: Image.asset( 'images/icon_tips_Q.png', width: 34.w, height: 32.w, color: Colors.white, ), onTap: () { ShowTipView().showSureAlertDialog('1.锁没有联网,密码、IC卡、指纹等开门记录无法实时上传,可以点击右上角按钮,然后读取记录。'.tr + '\n' + '2.如果您需要保留历史记录,可以点击右上角按钮,然后导出记录'.tr, tipTitle: '看不到操作记录,可能原因有'.tr, sureStr: '我知道了'.tr); }, )), Visibility( visible: CommonDataManage().currentKeyInfo.isLockOwner == 1 || CommonDataManage().currentKeyInfo.keyRight == 1, child: PopupMenuButton( onSelected: _onMenuItemSelected, color: Colors.black, itemBuilder: (BuildContext context) { return >[ _buildCustomPopupMenuItem('读取记录'.tr), if (CommonDataManage().currentKeyInfo.isLockOwner == 1) const PopupMenuDivider(), if (CommonDataManage().currentKeyInfo.isLockOwner == 1) _buildCustomPopupMenuItem('清空记录'.tr), const PopupMenuDivider(), _buildCustomPopupMenuItem('导出记录'.tr), ]; }, icon: Image.asset( 'images/icon_bar_more.png', height: 30.h, width: 10.w, ), offset: Offset(0, 70.h), // 设置弹出框位置偏移量 ), ), ], ), body: Column( mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.start, children: [topAdvancedCalendarWidget(), eventDropDownWidget(), Expanded(child: timeLineView())], ), floatingActionButton: Visibility( visible: _isAtBottom, child: FloatingActionButton( onPressed: () { _scrollController.animateTo( 0.0, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); }, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(48.w), ), backgroundColor: AppColors.mainColor, child: Icon( Icons.arrow_upward, color: Colors.white, size: 48.w, ), ), ), ); } // PopupMenuItem _buildCustomPopupMenuItem(String value) { return PopupMenuItem( value: value, height: 46.h, child: SizedBox( height: 46.h, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( value, style: TextStyle(color: Colors.white, fontSize: 22.sp), ), ], ), ), ); } void _onMenuItemSelected(String value) { if (value == '读取记录'.tr) { logic.getLockRecordLastUploadDataTime(); } else if (value == '清空记录'.tr) { ShowCupertinoAlertView().showClearOperationRecordAlert(clearClick: () { logic.clearOperationRecordRequest(); }); } else if (value == '导出记录'.tr) { showDialog( context: context, builder: (BuildContext context) { return ExportRecordDialog( onExport: (String filePath) { Get.toNamed(Routers.exportSuccessPage, arguments: {'filePath': filePath}); }, ); }, ); } } //顶部日历小部件 Widget topAdvancedCalendarWidget() { return Container( margin: EdgeInsets.only(top: 20.h, left: 30.w, bottom: 10.h, right: 20.w), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildWeekCalendar(), ], ), ); } //事件下拉框组件 Widget eventDropDownWidget() { return Container( margin: EdgeInsets.only(top: 20.h, left: 30.w, bottom: 10.h, right: 20.w), child: Row( mainAxisAlignment: MainAxisAlignment.start, children: [ Obx( () => XSDropDownWidget( items: state.getDropDownItemList, value: state.dropdownTitle.value, valueChanged: (value) async { state.dropdownValue.value = int.parse(value); await logic.mockNetworkDataRequest(isRefresh: true); }, ), ), ], ), ); } //时间轴组件 Widget timeLineView() { return Container( margin: EdgeInsets.only(left: 20.w, right: 20.w, bottom: 20.h, top: 20.h), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16.w), ), child: Obx( () => state.lockLogItemList.isNotEmpty ? Timeline.tileBuilder( controller: _scrollController, builder: _timelineBuilderWidget(), theme: TimelineThemeData( nodePosition: 0.04, //居左侧距离 connectorTheme: const ConnectorThemeData( thickness: 1.0, color: AppColors.greyLineColor, indent: 0.5, ), indicatorTheme: const IndicatorThemeData( size: 8.0, color: AppColors.greyLineColor, position: 0.4, ), ), ) : NoData(), ), ); } String formatTimestampToDateTimeYYYYMMDD(int timestampMs) { DateTime dateTime = DateTime.fromMillisecondsSinceEpoch(timestampMs); DateFormat formatter = DateFormat('MM${'月'.tr}dd${'日'.tr}'); // 格式:2025-08-18 14:30 return formatter.format(dateTime); } String formatTimestampToHHmm(int timestampMs) { // 2. 创建 DateTime 对象 DateTime dateTime = DateTime.fromMillisecondsSinceEpoch(timestampMs); // 3. 使用 DateFormat 格式化为 "HH:mm" DateFormat formatter = DateFormat('HH:mm'); return formatter.format(dateTime); } bool _checkIsVideoOrImagesType(DoorLockLogDataItem item) { final recordType = item.recordType; switch (recordType) { case 130: case 220: return true; default: return false; } } String _buildIDByType(DoorLockLogDataItem item) { final recordType = item.recordType; switch (recordType) { case 10: return '${formatTimestampToHHmm(item.operateDate!)} ' + '指纹'.tr + '开锁'.tr + '(${_formatUserNameOrId(item)})'; case 20: return '${formatTimestampToHHmm(item.operateDate!)} ' + '密码'.tr + '开锁'.tr + '(${_formatUserNameOrId(item)})' + '(${'密码'.tr}:${item.keyboardPwd})'; case 30: return '${formatTimestampToHHmm(item.operateDate!)} ' + '卡'.tr + '开锁'.tr + '(${_formatUserNameOrId(item)})'; case 40: if (item.username != null && item.username != '') { return '${formatTimestampToHHmm(item.operateDate!)} ' + '蓝牙'.tr + '开锁'.tr + '(${_formatUserNameOrId(item)})'; } else { return '${formatTimestampToHHmm(item.operateDate!)} ' + '蓝牙'.tr + '开锁'.tr + '(' + 'ID'.tr + ':${item.userid})'; } case 50: return '${formatTimestampToHHmm(item.operateDate!)} ' + '组合模式'.tr + '开锁'.tr + '(${_formatUserNameOrId(item)})'; case 60: return '${formatTimestampToHHmm(item.operateDate!)} ' + '添加'.tr + '指纹'.tr + '(${_formatUserNameOrId(item)})'; case 70: return '${formatTimestampToHHmm(item.operateDate!)} ' + '添加'.tr + '密码'.tr + '(${_formatUserNameOrId(item)})'; case 80: return '${formatTimestampToHHmm(item.operateDate!)} ' + '添加'.tr + '卡'.tr + '(${_formatUserNameOrId(item)})'; case 90: return '${formatTimestampToHHmm(item.operateDate!)} ' + '删除'.tr + '指纹'.tr + '(${_formatUserNameOrId(item)})'; case 100: return '${formatTimestampToHHmm(item.operateDate!)} ' + '删除'.tr + '密码'.tr + '(${_formatUserNameOrId(item)})'; case 110: return '${formatTimestampToHHmm(item.operateDate!)} ' + '删除'.tr + '卡'.tr + '(${_formatUserNameOrId(item)})'; case 160: return '${formatTimestampToHHmm(item.operateDate!)} ' + '人脸'.tr + '开锁'.tr + '(${_formatUserNameOrId(item)})'; case 190: return '${formatTimestampToHHmm(item.operateDate!)} ' + '胁迫指纹'.tr + '开锁'.tr + '(${_formatUserNameOrId(item)})'; case 200: return '${formatTimestampToHHmm(item.operateDate!)} ' + '胁迫密码'.tr + '开锁'.tr + '(${_formatUserNameOrId(item)})'; case 210: return '${formatTimestampToHHmm(item.operateDate!)} ' + '胁迫卡片'.tr + '开锁'.tr + '(${_formatUserNameOrId(item)})'; default: return item.recordStr ?? ''; } } // 提取为私有方法,更清晰 String _formatUserNameOrId(DoorLockLogDataItem item) { final recordStr = item.recordStr; final idMatch = RegExp(r'ID[::](\w+)').firstMatch(recordStr ?? ''); final idValue = idMatch?.group(1) ?? ''; final name = item.username ?? ''; // 如果用户名不为空,并且是以 ID: 或 ID: 开头的格式,则直接返回 ID:xxx if (name.isNotEmpty) { final idInNameMatch = RegExp(r'^ID[::].+').hasMatch(name); if (idInNameMatch) { return name; // 直接返回,例如 "ID:123" } else { return '${'昵称'.tr}:$name'; // 正常用户昵称,加前缀 } } // 如果用户名为空,使用 recordStr 中提取的 ID return 'ID:$idValue'; } Color _buildTextColorByType(DoorLockLogDataItem item) { final recordType = item.recordType; switch (recordType) { case 120: case 150: case 130: case 190: case 200: case 210: case 220: return Colors.red; default: return Colors.black; } } TimelineTileBuilder _timelineBuilderWidget() { return TimelineTileBuilder.fromStyle( contentsAlign: ContentsAlign.basic, itemCount: state.lockLogItemList.length, contentsBuilder: (BuildContext context, int index) { final DoorLockLogDataItem timelineData = state.lockLogItemList[index]; // 👇 提前计算第一个有 videoUrl 的索引(可以在 build 外层计算一次,避免重复) int? firstVideoIndex = state.lockLogItemList.indexWhere((item) => _checkIsVideoOrImagesType(item)); bool isInvalid = _checkIsVideoOrImagesType(timelineData) && ((timelineData.imagesUrl == null && timelineData.videoUrl == null) || (timelineData.videoUrl == '' && timelineData.imagesUrl == '')); String typeText = ''; if (timelineData.recordType == 130) { typeText = '图像'.tr; } else if (timelineData.recordType == 220) { typeText = '视频'.tr; } return GestureDetector( onTap: () { Get.toNamed( Routers.doorLockLogDetailPage, arguments: {'doorLockLogDataItem': timelineData}, ); }, child: Padding( padding: EdgeInsets.only(left: 20.w, top: 20.h, right: 20.w), child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('${formatTimestampToDateTimeYYYYMMDD(timelineData.operateDate!)}', style: TextStyle( fontSize: 20.sp, )), // 使用 SingleChildScrollView 实现横向滚动 SingleChildScrollView( scrollDirection: Axis.horizontal, // 横向滚动 child: RichText( textAlign: TextAlign.left, text: TextSpan( style: TextStyle( color: _buildTextColorByType(timelineData), fontSize: 24.sp, fontWeight: FontWeight.w600, ), children: [ TextSpan( text: _buildIDByType(timelineData) + (isInvalid ? '(${typeText}' + '已失效'.tr + ')' : ''), ), WidgetSpan( alignment: PlaceholderAlignment.middle, child: Visibility( visible: isInvalid, child: Icon( Icons.error, size: 24.sp, color: Colors.red, ), ), ), ], ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), SizedBox( height: 12.h, ), videoItem( RecordListData( recordId: state.lockLogItemList.value[index].recordId, recordType: state.lockLogItemList.value[index].recordType, operateDate: state.lockLogItemList.value[index].operateDate, imagesUrl: state.lockLogItemList.value[index].imagesUrl, videoUrl: state.lockLogItemList.value[index].videoUrl, ), ), SizedBox( height: 12.h, ), Visibility( visible: _checkIsVideoOrImagesType(timelineData) && index == firstVideoIndex, child: GestureDetector( onTap: () async { await logic.getWebPlayUrl(); }, child: Container( padding: EdgeInsets.all(8.w), decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.circular(8.r)), ), child: RichText( textAlign: TextAlign.center, text: TextSpan( children: [ // 普通文字部分 TextSpan( text: '${'您的图像和视频数据仅保留'.tr} ${state.rollingStorageDays.value} ${'天'.tr} ,${state.rollingStorageDays.value} ${'天'.tr} ${'后图像和视频数据将会失效,开通'.tr}', style: TextStyle( color: Colors.grey, fontSize: 16.sp, fontWeight: FontWeight.w600, height: 1.8, ), ), // 🔥 高亮且可点击的“云存会员”文本 TextSpan( text: '云存会员'.tr, style: TextStyle( color: AppColors.mainColor, fontSize: 22.sp, fontWeight: FontWeight.w800, // 更粗 decoration: TextDecoration.underline, decorationThickness: 1.5, height: 1.8, ), recognizer: TapGestureRecognizer() ..onTap = () async { // 👉 点击回调:跳转或弹窗 print('点击了“云存会员”'); await logic.getWebPlayUrl(); // 例如:Navigator.push(context, MaterialPageRoute(builder: ...)); }, ), // 后续文字 TextSpan( text: '服务,图像视频信息随心存!'.tr, style: TextStyle( color: Colors.grey, fontSize: 16.sp, fontWeight: FontWeight.w600, height: 1.8, ), ), ], ), ), ), ), ) ], ), ), ); }, ); } Widget videoItem(RecordListData recordData) { return GestureDetector( onTap: () { if (recordData.videoUrl != null && recordData.videoUrl!.isNotEmpty) { final lockLogItemList = state.lockLogItemList.value; final list = lockLogItemList .where((e) => (e.videoUrl != null && e.videoUrl!.isNotEmpty) || (e.imagesUrl != null && e.imagesUrl!.isNotEmpty)) .map( (e) => RecordListData( videoUrl: e.videoUrl, imagesUrl: e.imagesUrl, operateDate: e.operateDate, recordId: e.recordId, recordType: e.recordType, ), ) .toList(); final selectDateString = DateTool().dateToYMDString(state.startDate.value.toString()); final cloudStorageData = CloudStorageData(date: selectDateString, recordList: list); Get.toNamed(Routers.videoLogDetailPage, arguments: { 'recordData': recordData, 'videoDataList': [cloudStorageData] }); } else if (recordData.imagesUrl != null && recordData.imagesUrl!.isNotEmpty) { Navigator.push( context, MaterialPageRoute( builder: (context) => FullScreenImagePage( imageUrl: recordData.imagesUrl!, ), ), ); } }, child: ((recordData.imagesUrl != null && recordData.imagesUrl != '') || (recordData.videoUrl != null && recordData.videoUrl != '')) ? Container( width: 260.w, height: 260.h, margin: const EdgeInsets.all(0), color: Colors.white, child: ClipRRect( borderRadius: BorderRadius.circular(10.w), child: _buildImageOrVideoItem(recordData), ), ) : SizedBox.shrink(), ); } _buildImageOrVideoItem(RecordListData recordData) { if (recordData.videoUrl != null && recordData.videoUrl!.isNotEmpty) { return _buildVideoItem(recordData); } else if (recordData.imagesUrl != null && recordData.imagesUrl!.isNotEmpty) { return _buildImageItem(recordData); } else { return SizedBox.shrink(); } } _buildVideoItem(RecordListData recordData) { return VideoThumbnailImage(videoUrl: recordData.videoUrl!); } _buildImageItem(RecordListData recordData) { return RotatedBox( quarterTurns: -1, child: Image.network( recordData.imagesUrl!, fit: BoxFit.cover, errorBuilder: (BuildContext context, Object error, StackTrace? stackTrace) { // 图片加载失败时显示错误图片 return Image.asset( 'images/icon_unHaveData.png', // 错误图片路径 fit: BoxFit.cover, ); }, ), ); } @override void didChangeDependencies() { super.didChangeDependencies(); /// 路由订阅 AppRouteObserver().routeObserver.subscribe(this, ModalRoute.of(context)!); } @override void dispose() { /// 取消路由订阅 AppRouteObserver().routeObserver.unsubscribe(this); super.dispose(); } /// 从上级界面进入 当前界面即将出现 @override void didPush() { super.didPush(); state.ifCurrentScreen.value = true; } /// 返回上一个界面 当前界面即将消失 @override void didPop() { super.didPop(); logic.cancelBlueConnetctToastTimer(); if (EasyLoading.isShow) { EasyLoading.dismiss(animation: true); } state.ifCurrentScreen.value = false; } /// 从下级返回 当前界面即将出现 @override void didPopNext() { super.didPopNext(); state.ifCurrentScreen.value = true; } /// 进入下级界面 当前界面即将消失 @override void didPushNext() { super.didPushNext(); logic.cancelBlueConnetctToastTimer(); if (EasyLoading.isShow) { EasyLoading.dismiss(animation: true); } state.ifCurrentScreen.value = false; } List getCurrentWeekDates() { final now = DateTime.now(); // weekday: 1=周一, 2=周二, ..., 7=周日 // 计算距离上一个周日相差的天数 // 如果今天是周日,weekday == 7,偏移为 0 final int daysSinceSunday = now.weekday % 7; // 周一=1 -> %7=1, 周日=7 -> %7=0 final List weekDates = []; for (int i = 0; i < 7; i++) { final DateTime day = DateTime( now.year, now.month, now.day - daysSinceSunday + i, // 从周日开始累加 ); weekDates.add(day); } return weekDates; } Widget _buildWeekCalendar() { return Obx(() { final list = state.weekEventList.value; final dateSet = list .map((e) => DateTime.fromMillisecondsSinceEpoch(e.operateDate!)) .map((dt) => dt.withoutTime) // 转为年月日 .toSet(); // 用 Set 提升查找性能 AppLog.log('dateSet:${dateSet}'); return WeekCalendarView( hasData: (DateTime date) { return dateSet.contains(date.withoutTime); }, onDateSelected: (DateTime date) async { print('外部收到选中: $date'); state.operateDate = date.millisecondsSinceEpoch; state.startDate.value = DateTime(date.year, date.month, date.day).millisecondsSinceEpoch; state.endDate.value = DateTime(date.year, date.month, date.day, 23, 59, 59, 999).millisecondsSinceEpoch; await logic.mockNetworkDataRequest(isRefresh: true); }, onWeekChanged: (DateTime start, DateTime end) { state.startDate.value = start.millisecondsSinceEpoch; state.endDate.value = end.millisecondsSinceEpoch; logic.mockNetworkDataRequest(isRefresh: true); }, ); }); } }