From 339a50ceb10532c0e9c4368449df863ac193a4ee Mon Sep 17 00:00:00 2001 From: imoscarz Date: Wed, 24 Dec 2025 18:28:14 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=AF=BE?= =?UTF-8?q?=E7=A8=8B=E8=80=83=E5=8B=A4=E8=A1=A8=E6=A0=BC=E8=A7=86=E5=9B=BE?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E7=AD=9B=E9=80=89=E5=92=8C=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/flutter_i18n/en_US.yaml | 11 + assets/flutter_i18n/zh_CN.yaml | 11 + assets/flutter_i18n/zh_TW.yaml | 11 + .../class_attandance_card.dart | 19 +- .../class_attendance_detail.dart | 270 +++++++--- .../class_attendance_table.dart | 485 ++++++++++++++++++ .../class_attendance_view.dart | 25 +- lib/page/public_widget/both_side_sheet.dart | 10 +- pubspec.lock | 22 +- 9 files changed, 759 insertions(+), 105 deletions(-) create mode 100644 lib/page/class_attendance/class_attendance_table.dart diff --git a/assets/flutter_i18n/en_US.yaml b/assets/flutter_i18n/en_US.yaml index 9b55f031..45423c88 100644 --- a/assets/flutter_i18n/en_US.yaml +++ b/assets/flutter_i18n/en_US.yaml @@ -70,6 +70,17 @@ class_attendance: ineligible: "ineligible" eligible: "eligible" warning: "warning" + table: + course_name: "Course Name" + status: "Status" + attendance_rate: "Rate" + check_in: "Check-in" + absence: "Absence" + required: "Required" + leave: "Leave(P/S/O)" + filter: "Filter" + filter_all: "All" + showing_count: "Showing {count}/{total} courses" card: time: "Attendances" time_info: "{checkInCount} Checked / {absenceCount} Absences / {requiredCheckIn} Required" diff --git a/assets/flutter_i18n/zh_CN.yaml b/assets/flutter_i18n/zh_CN.yaml index 7cb96f5d..93844310 100644 --- a/assets/flutter_i18n/zh_CN.yaml +++ b/assets/flutter_i18n/zh_CN.yaml @@ -65,6 +65,17 @@ class_attendance: ineligible: "取消" eligible: "正常" warning: "危险" + table: + course_name: "课程名称" + status: "状态" + attendance_rate: "到课率" + check_in: "签到" + absence: "缺勤" + required: "应签" + leave: "请假(事/病/公)" + filter: "筛选" + filter_all: "全部" + showing_count: "显示 {count}/{total} 门课程" card: time: "签到次数" time_info: "{checkInCount} 已签 / {absenceCount} 缺勤 / {requiredCheckIn} 应签" diff --git a/assets/flutter_i18n/zh_TW.yaml b/assets/flutter_i18n/zh_TW.yaml index 93f60a1f..32d8612e 100644 --- a/assets/flutter_i18n/zh_TW.yaml +++ b/assets/flutter_i18n/zh_TW.yaml @@ -47,6 +47,17 @@ class_attendance: ineligible: 取消 eligible: 正常 warning: 危險 + table: + course_name: 課程名稱 + status: 狀態 + attendance_rate: 到課率 + check_in: 簽到 + absence: 缺勤 + required: 應籤 + leave: 請假(事/病/公) + filter: 篩選 + filter_all: 全部 + showing_count: 顯示 {count}/{total} 門課程 card: time: 簽到次數 time_info: '{checkInCount} 已籤 / {absenceCount} 缺勤 / {requiredCheckIn} 應籤' diff --git a/lib/page/class_attendance/class_attandance_card.dart b/lib/page/class_attendance/class_attandance_card.dart index e841b21d..747da89e 100644 --- a/lib/page/class_attendance/class_attandance_card.dart +++ b/lib/page/class_attendance/class_attandance_card.dart @@ -5,7 +5,7 @@ import 'package:flutter_i18n/flutter_i18n.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:watermeter/model/xidian_ids/class_attendance.dart'; import 'package:watermeter/page/class_attendance/class_attendance_detail.dart'; -import 'package:watermeter/page/public_widget/context_extension.dart'; +import 'package:watermeter/page/public_widget/both_side_sheet.dart'; import 'package:watermeter/page/public_widget/re_x_card.dart'; class CourseCard extends StatelessWidget { @@ -109,9 +109,22 @@ class CourseCard extends StatelessWidget { ], ), ).gestures( - onTap: () { + onTap: () async { if (!attendanceStatus.contains("unknown")) { - context.push(ClassAttendanceDetailView(classAttendance: course)); + await BothSideSheet.show( + context: context, + title: FlutterI18n.translate( + context, + "class_attendance.detail_title", + translationParams: { + "courseName": course.courseName, + }, + ), + child: ClassAttendanceDetailView( + classAttendance: course, + showAppBar: false, + ), + ); } }, ); diff --git a/lib/page/class_attendance/class_attendance_detail.dart b/lib/page/class_attendance/class_attendance_detail.dart index 988b4162..0c9b72b7 100644 --- a/lib/page/class_attendance/class_attendance_detail.dart +++ b/lib/page/class_attendance/class_attendance_detail.dart @@ -14,8 +14,13 @@ import 'package:watermeter/repository/xidian_ids/learning_session.dart'; class ClassAttendanceDetailView extends StatefulWidget { final ClassAttendance classAttendance; + final bool showAppBar; - const ClassAttendanceDetailView({super.key, required this.classAttendance}); + const ClassAttendanceDetailView({ + super.key, + required this.classAttendance, + this.showAppBar = true, + }); @override State createState() => @@ -79,107 +84,202 @@ class _ClassAttendanceDetailViewState extends State { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text( - FlutterI18n.translate( + final listView = PagedListView.separated( + state: PagingState(), + fetchNextPage: () {}, + builderDelegate: PagedChildBuilderDelegate( + firstPageProgressIndicatorBuilder: (context) => + const Center(child: CircularProgressIndicator()), + firstPageErrorIndicatorBuilder: (context) => ReloadWidget( + function: () async => _pagingController.refresh(), + errorStatus: _pagingController.error, + ), + newPageProgressIndicatorBuilder: (context) { + return Row( + children: [ + CircularProgressIndicator(), + Text("More to come"), + ], + ); + }, + noItemsFoundIndicatorBuilder: (context) => EmptyListView( + text: FlutterI18n.translate( context, - "class_attendance.detail_title", - translationParams: { - "courseName": widget.classAttendance.courseName, - }, + "class_attndance.no_attendance_record", ), + type: EmptyListViewType.rolling, ), - ), - body: RefreshIndicator( - onRefresh: () async => _pagingController.refresh(), - child: PagingListener( - controller: _pagingController, - builder: (context, state, fetchNextPage) => - PagedListView.separated( - state: state, - fetchNextPage: fetchNextPage, - builderDelegate: PagedChildBuilderDelegate( - firstPageProgressIndicatorBuilder: (context) => - const Center(child: CircularProgressIndicator()), - firstPageErrorIndicatorBuilder: (context) => ReloadWidget( - function: () async => _pagingController.refresh(), - errorStatus: _pagingController.error, + noMoreItemsIndicatorBuilder: (context) => + [ + Icon(Icons.sentiment_very_satisfied, size: 32), + SizedBox(width: 8), + Text( + "That's all folks!", + style: Theme.of(context).textTheme.titleLarge, ), - newPageProgressIndicatorBuilder: (context) { - return Row( - children: [ - CircularProgressIndicator(), - Text("More to come"), - ], - ); - }, - noItemsFoundIndicatorBuilder: (context) => EmptyListView( - text: FlutterI18n.translate( - context, - "class_attndance.no_attendance_record", - ), - type: EmptyListViewType.rolling, + ] + .toRow(mainAxisAlignment: MainAxisAlignment.center) + .center() + .padding(vertical: 12), + + itemBuilder: (context, item, index) => ReXCard( + title: Text(FlutterI18n.translate(context, item.signName)), + remaining: [ + ReXCardRemaining( + FlutterI18n.translate(context, item.signStatus), + ), + ], + bottomRow: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoRow( + FlutterI18n.translate( + context, + "class_attendance.detail_card.creator_name", + ), + item.creatorName, + ), + _buildInfoRow( + FlutterI18n.translate( + context, + "class_attendance.detail_card.start_time", + ), + item.starttime, + ), + if (item.submittime != null) + _buildInfoRow( + FlutterI18n.translate( + context, + "class_attendance.detail_card.summit_time", ), - noMoreItemsIndicatorBuilder: (context) => - [ - Icon(Icons.sentiment_very_satisfied, size: 32), - SizedBox(width: 8), - Text( - "That's all folks!", - style: Theme.of(context).textTheme.titleLarge, - ), - ] - .toRow(mainAxisAlignment: MainAxisAlignment.center) - .center() - .padding(vertical: 12), + item.submittime!, + ), + ], + ), + ).constrained(maxWidth: sheetMaxWidth).center(), + ), + separatorBuilder: (BuildContext context, int index) { + return const SizedBox(height: 4); + }, + padding: const EdgeInsets.symmetric( + horizontal: 12.5, + vertical: 9.0, + ), + ); - itemBuilder: (context, item, index) => ReXCard( - title: Text(FlutterI18n.translate(context, item.signName)), - remaining: [ - ReXCardRemaining( - FlutterI18n.translate(context, item.signStatus), - ), + final body = RefreshIndicator( + onRefresh: () async => _pagingController.refresh(), + child: PagingListener( + controller: _pagingController, + builder: (context, state, fetchNextPage) => + PagedListView.separated( + state: state, + fetchNextPage: fetchNextPage, + builderDelegate: PagedChildBuilderDelegate( + firstPageProgressIndicatorBuilder: (context) => + const Center(child: CircularProgressIndicator()), + firstPageErrorIndicatorBuilder: (context) => ReloadWidget( + function: () async => _pagingController.refresh(), + errorStatus: _pagingController.error, + ), + newPageProgressIndicatorBuilder: (context) { + return Row( + children: [ + CircularProgressIndicator(), + Text("More to come"), ], - bottomRow: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildInfoRow( - FlutterI18n.translate( - context, - "class_attendance.detail_card.creator_name", + ); + }, + noItemsFoundIndicatorBuilder: (context) => EmptyListView( + text: FlutterI18n.translate( + context, + "class_attndance.no_attendance_record", + ), + type: EmptyListViewType.rolling, + ), + noMoreItemsIndicatorBuilder: (context) => + [ + Icon(Icons.sentiment_very_satisfied, size: 32), + SizedBox(width: 8), + Text( + "That's all folks!", + style: Theme.of(context).textTheme.titleLarge, ), - item.creatorName, + ] + .toRow(mainAxisAlignment: MainAxisAlignment.center) + .center() + .padding(vertical: 12), + + itemBuilder: (context, item, index) => ReXCard( + title: Text(FlutterI18n.translate(context, item.signName)), + remaining: [ + ReXCardRemaining( + FlutterI18n.translate(context, item.signStatus), + ), + ], + bottomRow: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoRow( + FlutterI18n.translate( + context, + "class_attendance.detail_card.creator_name", + ), + item.creatorName, + ), + _buildInfoRow( + FlutterI18n.translate( + context, + "class_attendance.detail_card.start_time", ), + item.starttime, + ), + if (item.submittime != null) _buildInfoRow( FlutterI18n.translate( context, - "class_attendance.detail_card.start_time", + "class_attendance.detail_card.summit_time", ), - item.starttime, + item.submittime!, ), - if (item.submittime != null) - _buildInfoRow( - FlutterI18n.translate( - context, - "class_attendance.detail_card.summit_time", - ), - item.submittime!, - ), - ], - ), - ).constrained(maxWidth: sheetMaxWidth).center(), - ), - separatorBuilder: (BuildContext context, int index) { - return const SizedBox(height: 4); - }, - padding: const EdgeInsets.symmetric( - horizontal: 12.5, - vertical: 9.0, - ), + ], + ), + ).constrained(maxWidth: sheetMaxWidth).center(), ), + separatorBuilder: (BuildContext context, int index) { + return const SizedBox(height: 4); + }, + padding: const EdgeInsets.symmetric( + horizontal: 12.5, + vertical: 9.0, + ), + ), + ), + ); + + if (!widget.showAppBar) { + return SafeArea( + top: true, + bottom: false, + left: false, + right: false, + child: body, + ); + } + + return Scaffold( + appBar: AppBar( + title: Text( + FlutterI18n.translate( + context, + "class_attendance.detail_title", + translationParams: { + "courseName": widget.classAttendance.courseName, + }, + ), ), ), + body: body, ); } } diff --git a/lib/page/class_attendance/class_attendance_table.dart b/lib/page/class_attendance/class_attendance_table.dart new file mode 100644 index 00000000..7e3863b2 --- /dev/null +++ b/lib/page/class_attendance/class_attendance_table.dart @@ -0,0 +1,485 @@ +// Copyright 2025 BenderBlog Rodriguez and contributors. +// Copyright 2025 Traintime PDA Authors +// SPDX-License-Identifier: MPL-2.0 + +import 'package:flutter/material.dart'; +import 'package:flutter_i18n/flutter_i18n.dart'; +import 'package:watermeter/model/xidian_ids/class_attendance.dart'; +import 'package:watermeter/page/class_attendance/class_attendance_detail.dart'; +import 'package:watermeter/page/public_widget/both_side_sheet.dart'; + +class ClassAttendanceTable extends StatefulWidget { + final List courses; + final Map classTimes; + + const ClassAttendanceTable({ + super.key, + required this.courses, + required this.classTimes, + }); + + @override + State createState() => _ClassAttendanceTableState(); +} + +class _ClassAttendanceTableState extends State { + String? _selectedFilter; + int? _sortColumnIndex = 1; // 默认按状态列排序 + bool _sortAscending = true; + + int _getStatusPriority(String status) { + if (status.contains("ineligible")) return 0; // 最高优先级 + if (status.contains("warning")) return 1; + if (status.contains("eligible")) return 2; + return 3; // unknown 最低优先级 + } + + List get _filteredCourses { + List filtered; + + if (_selectedFilter == null || _selectedFilter == 'all') { + filtered = widget.courses.toList(); + } else { + filtered = widget.courses.where((course) { + final totalTimes = widget.classTimes[course.courseName] ?? 0; + final status = _getAttendanceStatus(course, totalTimes); + return status == _selectedFilter; + }).toList(); + } + + // 应用排序 + if (_sortColumnIndex != null) { + filtered.sort((a, b) { + int comparison = 0; + final totalTimesA = widget.classTimes[a.courseName] ?? 0; + final totalTimesB = widget.classTimes[b.courseName] ?? 0; + + switch (_sortColumnIndex) { + case 0: // 课程名称 + comparison = a.courseName.compareTo(b.courseName); + break; + case 1: // 状态 + final statusA = _getAttendanceStatus(a, totalTimesA); + final statusB = _getAttendanceStatus(b, totalTimesB); + comparison = _getStatusPriority( + statusA, + ).compareTo(_getStatusPriority(statusB)); + break; + case 2: // 到课率 + final rateA = + double.tryParse(a.attendanceRate.replaceAll(" %", "")) ?? 0; + final rateB = + double.tryParse(b.attendanceRate.replaceAll(" %", "")) ?? 0; + comparison = rateA.compareTo(rateB); + break; + case 3: // 签到 + final checkInA = int.tryParse(a.checkInCount) ?? 0; + final checkInB = int.tryParse(b.checkInCount) ?? 0; + comparison = checkInA.compareTo(checkInB); + break; + case 4: // 缺勤 + final absenceA = int.tryParse(a.absenceCount) ?? 0; + final absenceB = int.tryParse(b.absenceCount) ?? 0; + comparison = absenceA.compareTo(absenceB); + break; + case 5: // 应签 + final requiredA = int.tryParse(a.requiredCheckIn) ?? 0; + final requiredB = int.tryParse(b.requiredCheckIn) ?? 0; + comparison = requiredA.compareTo(requiredB); + break; + } + + return _sortAscending ? comparison : -comparison; + }); + } + + return filtered; + } + + String _getAttendanceStatus(ClassAttendance course, int totalTimes) { + final timeToHaveError = (totalTimes / 4).floor(); + final absenceNum = int.tryParse(course.absenceCount) ?? 0; + final attandanceRatio = double.tryParse( + course.attendanceRate.replaceAll(" %", ""), + ); + + if (attandanceRatio == null) { + return "class_attendance.course_state.unknown"; + } else if (timeToHaveError < absenceNum) { + return "class_attendance.course_state.ineligible"; + } else if (attandanceRatio >= 90.0 || timeToHaveError >= absenceNum) { + return "class_attendance.course_state.eligible"; + } else { + return "class_attendance.course_state.warning"; + } + } + + Color _getStatusColor(String status) { + if (status.contains("ineligible")) { + return Colors.red; + } else if (status.contains("warning")) { + return Colors.orange; + } else if (status.contains("eligible")) { + return Colors.green; + } else { + return Colors.grey; + } + } + + @override + Widget build(BuildContext context) { + final filteredCourses = _filteredCourses; + + return Column( + children: [ + // 筛选器行 + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + border: Border( + bottom: BorderSide(color: Theme.of(context).dividerColor), + ), + ), + child: Row( + children: [ + Text( + FlutterI18n.translate(context, "class_attendance.table.filter"), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(width: 16), + Expanded( + child: Wrap( + spacing: 8, + children: [ + FilterChip( + label: Text( + FlutterI18n.translate( + context, + "class_attendance.table.filter_all", + ), + ), + selected: + _selectedFilter == null || _selectedFilter == 'all', + onSelected: (selected) { + setState(() { + _selectedFilter = selected ? 'all' : null; + }); + }, + ), + FilterChip( + label: Text( + FlutterI18n.translate( + context, + "class_attendance.course_state.ineligible", + ), + ), + selected: + _selectedFilter == + 'class_attendance.course_state.ineligible', + selectedColor: Colors.red.withOpacity(0.2), + onSelected: (selected) { + setState(() { + _selectedFilter = selected + ? 'class_attendance.course_state.ineligible' + : null; + }); + }, + ), + FilterChip( + label: Text( + FlutterI18n.translate( + context, + "class_attendance.course_state.warning", + ), + ), + selected: + _selectedFilter == + 'class_attendance.course_state.warning', + selectedColor: Colors.orange.withOpacity(0.2), + onSelected: (selected) { + setState(() { + _selectedFilter = selected + ? 'class_attendance.course_state.warning' + : null; + }); + }, + ), + FilterChip( + label: Text( + FlutterI18n.translate( + context, + "class_attendance.course_state.eligible", + ), + ), + selected: + _selectedFilter == + 'class_attendance.course_state.eligible', + selectedColor: Colors.green.withOpacity(0.2), + onSelected: (selected) { + setState(() { + _selectedFilter = selected + ? 'class_attendance.course_state.eligible' + : null; + }); + }, + ), + FilterChip( + label: Text( + FlutterI18n.translate( + context, + "class_attendance.course_state.unknown", + ), + ), + selected: + _selectedFilter == + 'class_attendance.course_state.unknown', + selectedColor: Colors.grey.withOpacity(0.2), + onSelected: (selected) { + setState(() { + _selectedFilter = selected + ? 'class_attendance.course_state.unknown' + : null; + }); + }, + ), + ], + ), + ), + Text( + FlutterI18n.translate( + context, + "class_attendance.table.showing_count", + translationParams: { + "count": filteredCourses.length.toString(), + "total": widget.courses.length.toString(), + }, + ), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + // 表格内容 + Expanded( + child: SingleChildScrollView( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Container( + margin: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(8), + ), + child: DataTable( + headingRowColor: WidgetStateProperty.all( + Theme.of(context).colorScheme.surfaceContainerHighest, + ), + sortColumnIndex: _sortColumnIndex, + sortAscending: _sortAscending, + columnSpacing: 12, + horizontalMargin: 12, + dataRowMinHeight: 48, + dataRowMaxHeight: double.infinity, + columns: [ + DataColumn( + label: Text( + FlutterI18n.translate( + context, + "class_attendance.table.course_name", + ), + style: const TextStyle(fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + ), + onSort: (columnIndex, ascending) { + setState(() { + _sortColumnIndex = columnIndex; + _sortAscending = ascending; + }); + }, + ), + DataColumn( + label: Text( + FlutterI18n.translate( + context, + "class_attendance.table.status", + ), + style: const TextStyle(fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + ), + onSort: (columnIndex, ascending) { + setState(() { + _sortColumnIndex = columnIndex; + _sortAscending = ascending; + }); + }, + ), + DataColumn( + label: Text( + FlutterI18n.translate( + context, + "class_attendance.table.attendance_rate", + ), + style: const TextStyle(fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + ), + numeric: true, + onSort: (columnIndex, ascending) { + setState(() { + _sortColumnIndex = columnIndex; + _sortAscending = ascending; + }); + }, + ), + DataColumn( + label: Text( + FlutterI18n.translate( + context, + "class_attendance.table.check_in", + ), + style: const TextStyle(fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + ), + numeric: true, + onSort: (columnIndex, ascending) { + setState(() { + _sortColumnIndex = columnIndex; + _sortAscending = ascending; + }); + }, + ), + DataColumn( + label: Text( + FlutterI18n.translate( + context, + "class_attendance.table.absence", + ), + style: const TextStyle(fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + ), + numeric: true, + onSort: (columnIndex, ascending) { + setState(() { + _sortColumnIndex = columnIndex; + _sortAscending = ascending; + }); + }, + ), + DataColumn( + label: Text( + FlutterI18n.translate( + context, + "class_attendance.table.required", + ), + style: const TextStyle(fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + ), + numeric: true, + onSort: (columnIndex, ascending) { + setState(() { + _sortColumnIndex = columnIndex; + _sortAscending = ascending; + }); + }, + ), + DataColumn( + label: Text( + FlutterI18n.translate( + context, + "class_attendance.table.leave", + ), + style: const TextStyle(fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + ), + ), + ], + rows: filteredCourses.map((course) { + final totalTimes = + widget.classTimes[course.courseName] ?? 0; + final status = _getAttendanceStatus(course, totalTimes); + final statusColor = _getStatusColor(status); + + return DataRow( + cells: [ + DataCell( + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 200), + child: Text( + course.courseName, + softWrap: true, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + onTap: () async { + if (!status.contains("unknown")) { + await BothSideSheet.show( + context: context, + title: FlutterI18n.translate( + context, + "class_attendance.detail_title", + translationParams: { + "courseName": course.courseName, + }, + ), + child: ClassAttendanceDetailView( + classAttendance: course, + showAppBar: false, + ), + ); + } + }, + ), + DataCell( + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + FlutterI18n.translate(context, status), + style: TextStyle( + color: statusColor, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + DataCell(Text(course.attendanceRate)), + DataCell(Text(course.checkInCount)), + DataCell( + Text( + course.absenceCount, + style: TextStyle( + color: + int.tryParse(course.absenceCount) != null && + int.parse(course.absenceCount) > 0 + ? Colors.red + : null, + ), + ), + ), + DataCell(Text(course.requiredCheckIn)), + DataCell( + Text( + "${course.personalLeave}/${course.sickLeave}/${course.officialLeave}", + style: const TextStyle(fontSize: 12), + softWrap: true, + ), + ), + ], + ); + }).toList(), + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/page/class_attendance/class_attendance_view.dart b/lib/page/class_attendance/class_attendance_view.dart index 71af2290..b06274a3 100644 --- a/lib/page/class_attendance/class_attendance_view.dart +++ b/lib/page/class_attendance/class_attendance_view.dart @@ -8,6 +8,7 @@ import 'package:styled_widget/styled_widget.dart'; import 'package:watermeter/controller/classtable_controller.dart'; import 'package:watermeter/model/xidian_ids/class_attendance.dart'; import 'package:watermeter/page/class_attendance/class_attandance_card.dart'; +import 'package:watermeter/page/class_attendance/class_attendance_table.dart'; import 'package:watermeter/page/public_widget/empty_list_view.dart'; import 'package:watermeter/page/public_widget/public_widget.dart'; import 'package:watermeter/page/public_widget/timeline_widget/timeline_title.dart'; @@ -47,6 +48,9 @@ class _ClassAttendanceViewState extends State { @override Widget build(BuildContext context) { + // 判断是否使用表格视图:宽度大于 800 时使用表格(考虑表格需要更多空间) + final bool useTableView = MediaQuery.of(context).size.width > 800; + return Scaffold( appBar: AppBar( title: Text(FlutterI18n.translate(context, "class_attendance.title")), @@ -74,18 +78,29 @@ class _ClassAttendanceViewState extends State { ); } - final courses = snapshot.data!.map((classAttendance) { + final courses = snapshot.data!; + + // 使用表格视图(平板/电脑端) + if (useTableView) { + return ClassAttendanceTable( + courses: courses, + classTimes: classTimes, + ); + } + + // 使用卡片视图(移动端) + final courseCards = courses.map((classAttendance) { int times = classTimes[classAttendance.courseName] ?? 0; return CourseCard(course: classAttendance, totalTimes: times); }).toList(); - final warningCourses = courses.toList() + final warningCourses = courseCards.toList() ..retainWhere((e) => e.attendanceStatus.contains("warning")); - final ineligibleCourses = courses.toList() + final ineligibleCourses = courseCards.toList() ..retainWhere((e) => e.attendanceStatus.contains("ineligible")); - final eligibleCourses = courses.toList() + final eligibleCourses = courseCards.toList() ..retainWhere((e) => e.attendanceStatus.contains("eligible")); - final unknownCourses = courses.toList() + final unknownCourses = courseCards.toList() ..retainWhere((e) => e.attendanceStatus.contains("unknown")); return TimelineWidget( diff --git a/lib/page/public_widget/both_side_sheet.dart b/lib/page/public_widget/both_side_sheet.dart index f51ae770..41f1644d 100644 --- a/lib/page/public_widget/both_side_sheet.dart +++ b/lib/page/public_widget/both_side_sheet.dart @@ -118,7 +118,15 @@ class _BothSideSheetState extends State { onPressed: () => Navigator.pop(context), icon: const Icon(Icons.arrow_back), ), - Text(widget.title, style: Theme.of(context).textTheme.titleLarge), + Expanded( + child: Text( + widget.title, + style: Theme.of(context).textTheme.titleLarge, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + const SizedBox(width: 8), ], ), ); diff --git a/pubspec.lock b/pubspec.lock index 507eda9f..7f1f4eea 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -421,18 +421,18 @@ packages: dependency: "direct main" description: name: flex_color_scheme - sha256: ab854146f201d2d62cc251fd525ef023b84182c4a0bfe4ae4c18ffc505b412d3 + sha256: "6e713c27a2ebe63393a44d4bf9cdd2ac81e112724a4c69905fc41cbf231af11d" url: "https://pub.dev" source: hosted - version: "8.4.0" + version: "8.3.1" flex_seed_scheme: dependency: transitive description: name: flex_seed_scheme - sha256: a3183753bbcfc3af106224bff3ab3e1844b73f58062136b7499919f49f3667e7 + sha256: "828291a5a4d4283590541519d8b57821946660ac61d2e07d955f81cfcab22e5d" url: "https://pub.dev" source: hosted - version: "4.0.1" + version: "3.6.1" flutter: dependency: "direct main" description: flutter @@ -665,10 +665,10 @@ packages: dependency: "direct main" description: name: image - sha256: "48c11d0943b93b6fb29103d956ff89aafeae48f6058a3939649be2093dcff0bf" + sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c" url: "https://pub.dev" source: hosted - version: "4.7.1" + version: "4.7.2" infinite_scroll_pagination: dependency: "direct main" description: @@ -793,10 +793,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" mime: dependency: transitive description: @@ -1398,10 +1398,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.6" tflite_flutter: dependency: "direct main" description: @@ -1636,4 +1636,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.9.0 <4.0.0" - flutter: ">=3.38.0" + flutter: ">=3.35.0" From 7d65cb2d214d5c10cb20e9d0639376e4b2154a45 Mon Sep 17 00:00:00 2001 From: imoscarz Date: Wed, 24 Dec 2025 20:00:24 +0800 Subject: [PATCH 2/7] Update lib/page/class_attendance/class_attendance_detail.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/page/class_attendance/class_attendance_detail.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/page/class_attendance/class_attendance_detail.dart b/lib/page/class_attendance/class_attendance_detail.dart index 0c9b72b7..7f7d5ff9 100644 --- a/lib/page/class_attendance/class_attendance_detail.dart +++ b/lib/page/class_attendance/class_attendance_detail.dart @@ -199,7 +199,7 @@ class _ClassAttendanceDetailViewState extends State { ), noMoreItemsIndicatorBuilder: (context) => [ - Icon(Icons.sentiment_very_satisfied, size: 32), + const Icon(Icons.sentiment_very_satisfied, size: 32), SizedBox(width: 8), Text( "That's all folks!", From a95c09be46e989431ac754ed85c35b49eb871e23 Mon Sep 17 00:00:00 2001 From: imoscarz Date: Wed, 24 Dec 2025 20:00:50 +0800 Subject: [PATCH 3/7] Update lib/page/class_attendance/class_attendance_detail.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/page/class_attendance/class_attendance_detail.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/page/class_attendance/class_attendance_detail.dart b/lib/page/class_attendance/class_attendance_detail.dart index 7f7d5ff9..34d1000e 100644 --- a/lib/page/class_attendance/class_attendance_detail.dart +++ b/lib/page/class_attendance/class_attendance_detail.dart @@ -200,7 +200,7 @@ class _ClassAttendanceDetailViewState extends State { noMoreItemsIndicatorBuilder: (context) => [ const Icon(Icons.sentiment_very_satisfied, size: 32), - SizedBox(width: 8), + const SizedBox(width: 8), Text( "That's all folks!", style: Theme.of(context).textTheme.titleLarge, From 9b4202b43ed10550d208a4ed1bf985e34a1f6603 Mon Sep 17 00:00:00 2001 From: imoscarz Date: Wed, 24 Dec 2025 20:03:21 +0800 Subject: [PATCH 4/7] fix: Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- assets/flutter_i18n/en_US.yaml | 22 +++++++++---------- .../class_attendance_detail.dart | 12 +++++----- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/assets/flutter_i18n/en_US.yaml b/assets/flutter_i18n/en_US.yaml index 45423c88..ec8e302e 100644 --- a/assets/flutter_i18n/en_US.yaml +++ b/assets/flutter_i18n/en_US.yaml @@ -70,17 +70,17 @@ class_attendance: ineligible: "ineligible" eligible: "eligible" warning: "warning" - table: - course_name: "Course Name" - status: "Status" - attendance_rate: "Rate" - check_in: "Check-in" - absence: "Absence" - required: "Required" - leave: "Leave(P/S/O)" - filter: "Filter" - filter_all: "All" - showing_count: "Showing {count}/{total} courses" + table: + course_name: "Course Name" + status: "Status" + attendance_rate: "Rate" + check_in: "Check-in" + absence: "Absence" + required: "Required" + leave: "Leave(P/S/O)" + filter: "Filter" + filter_all: "All" + showing_count: "Showing {count}/{total} courses" card: time: "Attendances" time_info: "{checkInCount} Checked / {absenceCount} Absences / {requiredCheckIn} Required" diff --git a/lib/page/class_attendance/class_attendance_detail.dart b/lib/page/class_attendance/class_attendance_detail.dart index 34d1000e..833dca2e 100644 --- a/lib/page/class_attendance/class_attendance_detail.dart +++ b/lib/page/class_attendance/class_attendance_detail.dart @@ -97,8 +97,8 @@ class _ClassAttendanceDetailViewState extends State { newPageProgressIndicatorBuilder: (context) { return Row( children: [ - CircularProgressIndicator(), - Text("More to come"), + const CircularProgressIndicator(), + const Text("More to come"), ], ); }, @@ -111,8 +111,8 @@ class _ClassAttendanceDetailViewState extends State { ), noMoreItemsIndicatorBuilder: (context) => [ - Icon(Icons.sentiment_very_satisfied, size: 32), - SizedBox(width: 8), + const Icon(Icons.sentiment_very_satisfied, size: 32), + const SizedBox(width: 8), Text( "That's all folks!", style: Theme.of(context).textTheme.titleLarge, @@ -185,8 +185,8 @@ class _ClassAttendanceDetailViewState extends State { newPageProgressIndicatorBuilder: (context) { return Row( children: [ - CircularProgressIndicator(), - Text("More to come"), + const CircularProgressIndicator(), + const Text("More to come"), ], ); }, From 1295fcd19b1317fffdfa3b3039d3f44812f78610 Mon Sep 17 00:00:00 2001 From: imoscarz Date: Wed, 24 Dec 2025 20:04:57 +0800 Subject: [PATCH 5/7] fix: typo Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/page/class_attendance/class_attendance_table.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/page/class_attendance/class_attendance_table.dart b/lib/page/class_attendance/class_attendance_table.dart index 7e3863b2..51a59baa 100644 --- a/lib/page/class_attendance/class_attendance_table.dart +++ b/lib/page/class_attendance/class_attendance_table.dart @@ -99,15 +99,15 @@ class _ClassAttendanceTableState extends State { String _getAttendanceStatus(ClassAttendance course, int totalTimes) { final timeToHaveError = (totalTimes / 4).floor(); final absenceNum = int.tryParse(course.absenceCount) ?? 0; - final attandanceRatio = double.tryParse( + final attendanceRatio = double.tryParse( course.attendanceRate.replaceAll(" %", ""), ); - if (attandanceRatio == null) { + if (attendanceRatio == null) { return "class_attendance.course_state.unknown"; } else if (timeToHaveError < absenceNum) { return "class_attendance.course_state.ineligible"; - } else if (attandanceRatio >= 90.0 || timeToHaveError >= absenceNum) { + } else if (attendanceRatio >= 90.0 || timeToHaveError >= absenceNum) { return "class_attendance.course_state.eligible"; } else { return "class_attendance.course_state.warning"; From deb9f51e544bd2e205c89507d2c33fc4d1887199 Mon Sep 17 00:00:00 2001 From: imoscarz Date: Wed, 24 Dec 2025 20:16:06 +0800 Subject: [PATCH 6/7] fix: apply suggestions from code review. --- assets/flutter_i18n/en_US.yaml | 22 ++--- lib/model/xidian_ids/class_attendance.dart | 20 +++++ .../class_attandance_card.dart | 15 +--- .../class_attendance_detail.dart | 83 ------------------- .../class_attendance_table.dart | 27 ++---- 5 files changed, 37 insertions(+), 130 deletions(-) diff --git a/assets/flutter_i18n/en_US.yaml b/assets/flutter_i18n/en_US.yaml index ec8e302e..45423c88 100644 --- a/assets/flutter_i18n/en_US.yaml +++ b/assets/flutter_i18n/en_US.yaml @@ -70,17 +70,17 @@ class_attendance: ineligible: "ineligible" eligible: "eligible" warning: "warning" - table: - course_name: "Course Name" - status: "Status" - attendance_rate: "Rate" - check_in: "Check-in" - absence: "Absence" - required: "Required" - leave: "Leave(P/S/O)" - filter: "Filter" - filter_all: "All" - showing_count: "Showing {count}/{total} courses" + table: + course_name: "Course Name" + status: "Status" + attendance_rate: "Rate" + check_in: "Check-in" + absence: "Absence" + required: "Required" + leave: "Leave(P/S/O)" + filter: "Filter" + filter_all: "All" + showing_count: "Showing {count}/{total} courses" card: time: "Attendances" time_info: "{checkInCount} Checked / {absenceCount} Absences / {requiredCheckIn} Required" diff --git a/lib/model/xidian_ids/class_attendance.dart b/lib/model/xidian_ids/class_attendance.dart index 9559ad03..1d806b8f 100644 --- a/lib/model/xidian_ids/class_attendance.dart +++ b/lib/model/xidian_ids/class_attendance.dart @@ -60,6 +60,26 @@ class ClassAttendance { this.clazzId, this.cpi, }); + + /// Calculate the attendance status based on total class times + /// Returns a translation key for the status + String getAttendanceStatus(int totalTimes) { + final timeToHaveError = (totalTimes / 4).floor(); + final absenceNum = int.tryParse(absenceCount) ?? 0; + final attendanceRatio = double.tryParse( + attendanceRate.replaceAll(" %", ""), + ); + + if (attendanceRatio == null) { + return "class_attendance.course_state.unknown"; + } else if (timeToHaveError < absenceNum) { + return "class_attendance.course_state.ineligible"; + } else if (attendanceRatio >= 90.0 || timeToHaveError >= absenceNum) { + return "class_attendance.course_state.eligible"; + } else { + return "class_attendance.course_state.warning"; + } + } } @JsonSerializable(explicitToJson: true) diff --git a/lib/page/class_attendance/class_attandance_card.dart b/lib/page/class_attendance/class_attandance_card.dart index 747da89e..badc96fd 100644 --- a/lib/page/class_attendance/class_attandance_card.dart +++ b/lib/page/class_attendance/class_attandance_card.dart @@ -20,20 +20,7 @@ class CourseCard extends StatelessWidget { timeToHaveError = (totalTimes / 4).floor(); absenceNum = int.tryParse(course.absenceCount) ?? 0; remainAbsenceNum = timeToHaveError - absenceNum; - - double? attandanceRatio = double.tryParse( - course.attendanceRate.replaceAll(" %", ""), - ); - - if (attandanceRatio == null) { - attendanceStatus = "class_attendance.course_state.unknown"; - } else if (timeToHaveError < absenceNum) { - attendanceStatus = "class_attendance.course_state.ineligible"; - } else if (attandanceRatio >= 90.0 || timeToHaveError >= absenceNum) { - attendanceStatus = "class_attendance.course_state.eligible"; - } else { - attendanceStatus = "class_attendance.course_state.warning"; - } + attendanceStatus = course.getAttendanceStatus(totalTimes); } Widget _buildInfoRow(String label, String value) { diff --git a/lib/page/class_attendance/class_attendance_detail.dart b/lib/page/class_attendance/class_attendance_detail.dart index 833dca2e..f9648734 100644 --- a/lib/page/class_attendance/class_attendance_detail.dart +++ b/lib/page/class_attendance/class_attendance_detail.dart @@ -84,89 +84,6 @@ class _ClassAttendanceDetailViewState extends State { @override Widget build(BuildContext context) { - final listView = PagedListView.separated( - state: PagingState(), - fetchNextPage: () {}, - builderDelegate: PagedChildBuilderDelegate( - firstPageProgressIndicatorBuilder: (context) => - const Center(child: CircularProgressIndicator()), - firstPageErrorIndicatorBuilder: (context) => ReloadWidget( - function: () async => _pagingController.refresh(), - errorStatus: _pagingController.error, - ), - newPageProgressIndicatorBuilder: (context) { - return Row( - children: [ - const CircularProgressIndicator(), - const Text("More to come"), - ], - ); - }, - noItemsFoundIndicatorBuilder: (context) => EmptyListView( - text: FlutterI18n.translate( - context, - "class_attndance.no_attendance_record", - ), - type: EmptyListViewType.rolling, - ), - noMoreItemsIndicatorBuilder: (context) => - [ - const Icon(Icons.sentiment_very_satisfied, size: 32), - const SizedBox(width: 8), - Text( - "That's all folks!", - style: Theme.of(context).textTheme.titleLarge, - ), - ] - .toRow(mainAxisAlignment: MainAxisAlignment.center) - .center() - .padding(vertical: 12), - - itemBuilder: (context, item, index) => ReXCard( - title: Text(FlutterI18n.translate(context, item.signName)), - remaining: [ - ReXCardRemaining( - FlutterI18n.translate(context, item.signStatus), - ), - ], - bottomRow: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildInfoRow( - FlutterI18n.translate( - context, - "class_attendance.detail_card.creator_name", - ), - item.creatorName, - ), - _buildInfoRow( - FlutterI18n.translate( - context, - "class_attendance.detail_card.start_time", - ), - item.starttime, - ), - if (item.submittime != null) - _buildInfoRow( - FlutterI18n.translate( - context, - "class_attendance.detail_card.summit_time", - ), - item.submittime!, - ), - ], - ), - ).constrained(maxWidth: sheetMaxWidth).center(), - ), - separatorBuilder: (BuildContext context, int index) { - return const SizedBox(height: 4); - }, - padding: const EdgeInsets.symmetric( - horizontal: 12.5, - vertical: 9.0, - ), - ); - final body = RefreshIndicator( onRefresh: () async => _pagingController.refresh(), child: PagingListener( diff --git a/lib/page/class_attendance/class_attendance_table.dart b/lib/page/class_attendance/class_attendance_table.dart index 51a59baa..a47a04a4 100644 --- a/lib/page/class_attendance/class_attendance_table.dart +++ b/lib/page/class_attendance/class_attendance_table.dart @@ -42,7 +42,7 @@ class _ClassAttendanceTableState extends State { } else { filtered = widget.courses.where((course) { final totalTimes = widget.classTimes[course.courseName] ?? 0; - final status = _getAttendanceStatus(course, totalTimes); + final status = course.getAttendanceStatus(totalTimes); return status == _selectedFilter; }).toList(); } @@ -59,8 +59,8 @@ class _ClassAttendanceTableState extends State { comparison = a.courseName.compareTo(b.courseName); break; case 1: // 状态 - final statusA = _getAttendanceStatus(a, totalTimesA); - final statusB = _getAttendanceStatus(b, totalTimesB); + final statusA = a.getAttendanceStatus(totalTimesA); + final statusB = b.getAttendanceStatus(totalTimesB); comparison = _getStatusPriority( statusA, ).compareTo(_getStatusPriority(statusB)); @@ -96,24 +96,6 @@ class _ClassAttendanceTableState extends State { return filtered; } - String _getAttendanceStatus(ClassAttendance course, int totalTimes) { - final timeToHaveError = (totalTimes / 4).floor(); - final absenceNum = int.tryParse(course.absenceCount) ?? 0; - final attendanceRatio = double.tryParse( - course.attendanceRate.replaceAll(" %", ""), - ); - - if (attendanceRatio == null) { - return "class_attendance.course_state.unknown"; - } else if (timeToHaveError < absenceNum) { - return "class_attendance.course_state.ineligible"; - } else if (attendanceRatio >= 90.0 || timeToHaveError >= absenceNum) { - return "class_attendance.course_state.eligible"; - } else { - return "class_attendance.course_state.warning"; - } - } - Color _getStatusColor(String status) { if (status.contains("ineligible")) { return Colors.red; @@ -265,6 +247,7 @@ class _ClassAttendanceTableState extends State { child: SingleChildScrollView( child: SingleChildScrollView( scrollDirection: Axis.horizontal, + physics: const ClampingScrollPhysics(), child: Container( margin: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -396,7 +379,7 @@ class _ClassAttendanceTableState extends State { rows: filteredCourses.map((course) { final totalTimes = widget.classTimes[course.courseName] ?? 0; - final status = _getAttendanceStatus(course, totalTimes); + final status = course.getAttendanceStatus(totalTimes); final statusColor = _getStatusColor(status); return DataRow( From 0f9ff569de0bfe061a95ecfd022fb0f46270250c Mon Sep 17 00:00:00 2001 From: imoscarz Date: Thu, 25 Dec 2025 12:41:38 +0800 Subject: [PATCH 7/7] fix: ensure mounted state before accessing screen width --- lib/main.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/main.dart b/lib/main.dart index beef2136..e15cab60 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -116,7 +116,9 @@ class _MyAppState extends State { } WidgetsBinding.instance.addPostFrameCallback((_) async { - double screenWidth = MediaQuery.of(context).size.width; + if (!mounted) return; + final screenWidth = PlatformDispatcher.instance.views.first.physicalSize.width / + PlatformDispatcher.instance.views.first.devicePixelRatio; log.info("Screen width: $screenWidth."); if (screenWidth < 480) { log.info("Vertical vision mode disabled!");