app-starlock/lib/main/lockDetail/doorLockLog/doorLockLog_page.dart
2025-08-28 13:59:22 +08:00

768 lines
27 KiB
Dart
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<DoorLockLogPage> createState() => _DoorLockLogPageState();
}
class _DoorLockLogPageState extends State<DoorLockLogPage> with RouteAware {
final ScrollController _scrollController = ScrollController();
final DoorLockLogLogic logic = Get.put(DoorLockLogLogic());
final DoorLockLogState state = Get.find<DoorLockLogLogic>().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: <Widget>[
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<String>(
onSelected: _onMenuItemSelected,
color: Colors.black,
itemBuilder: (BuildContext context) {
return <PopupMenuEntry<String>>[
_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: <Widget>[
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<String> _buildCustomPopupMenuItem(String value) {
return PopupMenuItem<String>(
value: value,
height: 46.h,
child: SizedBox(
height: 46.h,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
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: <String, String>{'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: <Widget>[
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:
if (item.username != null && item.username != '') {
return '${formatTimestampToHHmm(item.operateDate!)} ' +
'指纹'.tr +
'开锁'.tr +
'ID${item.username}';
} else {
return item.recordStr ?? '';
}
case 20:
return '${formatTimestampToHHmm(item.operateDate!)} ' +
'密码'.tr +
'开锁'.tr +
'${'昵称'.tr}${item.username}' +
'${'密码'.tr}${item.keyboardPwd}';
case 30:
return '${formatTimestampToHHmm(item.operateDate!)} ' +
''.tr +
'开锁'.tr +
'${'昵称'.tr}${item.username}';
case 40:
if (item.username != null && item.username != '') {
return '${formatTimestampToHHmm(item.operateDate!)} ' +
'蓝牙'.tr +
'开锁'.tr +
'' +
'${'昵称'.tr}'.tr +
'${item.username}';
} else {
return '${formatTimestampToHHmm(item.operateDate!)} ' +
'蓝牙'.tr +
'开锁'.tr +
'' +
'ID'.tr +
'${item.userid}';
}
case 50:
return '${formatTimestampToHHmm(item.operateDate!)} ' +
'组合模式'.tr +
'开锁'.tr +
'${'昵称'.tr}${item.username}';
case 60:
return '${formatTimestampToHHmm(item.operateDate!)} ' +
'添加'.tr +
'指纹'.tr +
'${'昵称'.tr}${item.username}';
case 70:
return '${formatTimestampToHHmm(item.operateDate!)} ' +
'添加'.tr +
'密码'.tr +
'${'昵称'.tr}${item.username}';
case 80:
return '${formatTimestampToHHmm(item.operateDate!)} ' +
'添加'.tr +
''.tr +
'${'昵称'.tr}${item.username}';
case 90:
return '${formatTimestampToHHmm(item.operateDate!)} ' +
'删除'.tr +
'指纹'.tr +
'${'昵称'.tr}${item.username}';
case 100:
return '${formatTimestampToHHmm(item.operateDate!)} ' +
'删除'.tr +
'密码'.tr +
'${'昵称'.tr}${item.username}';
case 110:
return '${formatTimestampToHHmm(item.operateDate!)} ' +
'删除'.tr +
''.tr +
'${'昵称'.tr}${item.username}';
case 160:
return '${formatTimestampToHHmm(item.operateDate!)} ' +
'人脸'.tr +
'开锁'.tr +
'${'昵称'.tr}${item.username}';
case 190:
return '${formatTimestampToHHmm(item.operateDate!)} ' +
'胁迫指纹'.tr +
'开锁'.tr +
'${'昵称'.tr}${item.username}';
case 200:
return '${formatTimestampToHHmm(item.operateDate!)} ' +
'胁迫密码'.tr +
'开锁'.tr +
'${'昵称'.tr}${item.username}';
case 210:
return '${formatTimestampToHHmm(item.operateDate!)} ' +
'胁迫卡片'.tr +
'开锁'.tr +
'${'昵称'.tr}${item.username}';
default:
return item.recordStr ?? '';
}
}
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: <Widget>[
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: <String, Object>{
'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<DateTime> 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<DateTime> 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);
},
);
});
}
}