4 Commits

Author SHA1 Message Date
Nedifinita
64401a6ce9 feat: add the function to hide records that are only valid for time. 2025-09-25 22:52:19 +08:00
Nedifinita
72f9dfe17b fix: repair record processing logic 2025-09-25 22:25:51 +08:00
Nedifinita
bf850eed38 refactor: optimize record card rendering logic and animation effects 2025-09-25 22:01:25 +08:00
Nedifinita
56689fc993 feat: optimize record list 2025-09-25 21:45:52 +08:00
8 changed files with 290 additions and 94 deletions

View File

@@ -149,7 +149,7 @@ class TrainRecord {
final lbjClassValue = lbjClass.trim();
final trainValue = train.trim();
if (trainValue == "<NUL>") {
if (trainValue == "<NUL>" || trainValue.contains("-----")) {
return "";
}

View File

@@ -1,8 +1,12 @@
import 'dart:math' as math;
import 'dart:isolate';
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:scrollview_observer/scrollview_observer.dart';
import '../models/merged_record.dart';
import '../services/database_service.dart';
import '../models/train_record.dart';
@@ -31,6 +35,9 @@ class HistoryScreenState extends State<HistoryScreen> {
final Set<String> _selectedRecords = {};
final Map<String, bool> _expandedStates = {};
final ScrollController _scrollController = ScrollController();
final ListObserverController _observerController =
ListObserverController(controller: null)..cacheJumpIndexOffset = false;
late final ChatScrollObserver _chatObserver;
bool _isAtTop = true;
MergeSettings _mergeSettings = MergeSettings();
double _itemHeightCache = 0.0;
@@ -53,9 +60,17 @@ class HistoryScreenState extends State<HistoryScreen> {
});
}
Future<void> reloadRecords() async {
await loadRecords(scrollToTop: false);
}
@override
void initState() {
super.initState();
_chatObserver = ChatScrollObserver(_observerController)
..toRebuildScrollViewCallback = () {
setState(() {});
};
_scrollController.addListener(() {
if (_scrollController.position.atEdge) {
if (_scrollController.position.pixels == 0) {
@@ -73,6 +88,7 @@ class HistoryScreenState extends State<HistoryScreen> {
@override
void dispose() {
_scrollController.dispose();
_observerController.controller?.dispose();
super.dispose();
}
@@ -81,7 +97,71 @@ class HistoryScreenState extends State<HistoryScreen> {
final allRecords = await DatabaseService.instance.getAllRecords();
final settingsMap = await DatabaseService.instance.getAllSettings() ?? {};
_mergeSettings = MergeSettings.fromMap(settingsMap);
final items = MergeService.getMixedList(allRecords, _mergeSettings);
List<TrainRecord> filteredRecords = allRecords;
if ((settingsMap['hideTimeOnlyRecords'] ?? 0) == 1) {
int hiddenCount = 0;
int shownCount = 0;
filteredRecords = allRecords.where((record) {
bool isFieldMeaningful(String field) {
if (field.isEmpty) return false;
String cleaned = field.replaceAll('<NUL>', '').trim();
if (cleaned.isEmpty) return false;
if (cleaned.runes
.every((r) => r == '*'.runes.first || r == ' '.runes.first))
return false;
return true;
}
final hasTrainNumber = isFieldMeaningful(record.fullTrainNumber) &&
!record.fullTrainNumber.contains("-----");
final hasDirection = record.direction == 1 || record.direction == 3;
final hasLocoInfo = isFieldMeaningful(record.locoType) ||
isFieldMeaningful(record.loco);
final hasRoute = isFieldMeaningful(record.route);
final hasPosition = isFieldMeaningful(record.position);
final hasSpeed =
isFieldMeaningful(record.speed) && record.speed != "NUL";
final hasPositionInfo = isFieldMeaningful(record.positionInfo);
final hasTrainType =
isFieldMeaningful(record.trainType) && record.trainType != "未知";
final hasLbjClass =
isFieldMeaningful(record.lbjClass) && record.lbjClass != "NA";
final hasTrain = isFieldMeaningful(record.train) &&
!record.train.contains("-----");
final shouldShow = hasTrainNumber ||
hasDirection ||
hasLocoInfo ||
hasRoute ||
hasPosition ||
hasSpeed ||
hasPositionInfo ||
hasTrainType ||
hasLbjClass ||
hasTrain;
if (!shouldShow) {
hiddenCount++;
} else {
shownCount++;
}
return shouldShow;
}).toList();
}
final items = MergeService.getMixedList(filteredRecords, _mergeSettings);
if (mounted) {
final hasDataChanged = _hasDataChanged(items);
@@ -112,6 +192,58 @@ class HistoryScreenState extends State<HistoryScreen> {
final settingsMap = await DatabaseService.instance.getAllSettings() ?? {};
_mergeSettings = MergeSettings.fromMap(settingsMap);
if ((settingsMap['hideTimeOnlyRecords'] ?? 0) == 1) {
bool isFieldMeaningful(String field) {
if (field.isEmpty) return false;
String cleaned = field.replaceAll('<NUL>', '').trim();
if (cleaned.isEmpty) return false;
if (cleaned.runes
.every((r) => r == '*'.runes.first || r == ' '.runes.first))
return false;
return true;
}
final hasTrainNumber = isFieldMeaningful(newRecord.fullTrainNumber) &&
!newRecord.fullTrainNumber.contains("-----");
final hasDirection =
newRecord.direction == 1 || newRecord.direction == 3;
final hasLocoInfo = isFieldMeaningful(newRecord.locoType) ||
isFieldMeaningful(newRecord.loco);
final hasRoute = isFieldMeaningful(newRecord.route);
final hasPosition = isFieldMeaningful(newRecord.position);
final hasSpeed =
isFieldMeaningful(newRecord.speed) && newRecord.speed != "NUL";
final hasPositionInfo = isFieldMeaningful(newRecord.positionInfo);
final hasTrainType = isFieldMeaningful(newRecord.trainType) &&
newRecord.trainType != "未知";
final hasLbjClass =
isFieldMeaningful(newRecord.lbjClass) && newRecord.lbjClass != "NA";
final hasTrain = isFieldMeaningful(newRecord.train) &&
!newRecord.train.contains("-----");
if (!hasTrainNumber &&
!hasDirection &&
!hasLocoInfo &&
!hasRoute &&
!hasPosition &&
!hasSpeed &&
!hasPositionInfo &&
!hasTrainType &&
!hasLbjClass &&
!hasTrain) {
return;
}
}
final isNewRecord = !_displayItems.any((item) {
if (item is TrainRecord) {
return item.uniqueId == newRecord.uniqueId;
@@ -123,44 +255,29 @@ class HistoryScreenState extends State<HistoryScreen> {
if (!isNewRecord) return;
final allRecords = await DatabaseService.instance.getAllRecords();
final items = MergeService.getMixedList(allRecords, _mergeSettings);
if (mounted) {
final previousScrollOffset = _scrollController.hasClients ? _scrollController.offset : 0.0;
final previousItemCount = _displayItems.length;
if (!_isAtTop) {
_chatObserver.standby();
}
final allRecords = await DatabaseService.instance.getAllRecords();
final items = MergeService.getMixedList(allRecords, _mergeSettings);
final hasDataChanged = _hasDataChanged(items);
if (hasDataChanged) {
setState(() {
_displayItems.clear();
_displayItems.addAll(items);
});
}
setState(() {
_displayItems.clear();
_displayItems.addAll(items);
});
if (_scrollController.hasClients) {
if (_isAtTop) {
_scrollController.jumpTo(0.0);
} else {
final newItemCount = items.length;
final itemDifference = newItemCount - previousItemCount;
if (itemDifference > 0 && previousScrollOffset > 0) {
final itemHeight = _getEstimatedItemHeight();
final adjustedOffset = previousScrollOffset + (itemDifference * itemHeight);
_scrollController.jumpTo(adjustedOffset.clamp(0.0, _scrollController.position.maxScrollExtent));
}
}
if (_isAtTop && _scrollController.hasClients) {
_scrollController.jumpTo(0.0);
}
}
} catch (e) {}
}
double _getEstimatedItemHeight() {
if (_itemHeightCache > 0) {
return _itemHeightCache;
}
return 85.0;
}
bool _hasDataChanged(List<Object> newItems) {
if (_displayItems.length != newItems.length) return true;
@@ -194,8 +311,12 @@ class HistoryScreenState extends State<HistoryScreen> {
Text('暂无记录', style: TextStyle(color: Colors.white, fontSize: 18))
]));
}
return ListView.builder(
return ListViewObserver(
controller: _observerController,
child: ListView.builder(
controller: _scrollController,
physics: ChatObserverClampingScrollPhysics(observer: _chatObserver),
shrinkWrap: _chatObserver.isShrinkWrap,
padding: const EdgeInsets.all(16.0),
itemCount: _displayItems.length,
itemBuilder: (context, index) {
@@ -203,10 +324,12 @@ class HistoryScreenState extends State<HistoryScreen> {
if (item is MergedTrainRecord) {
return _buildMergedRecordCard(item);
} else if (item is TrainRecord) {
return _buildRecordCard(item);
return _buildRecordCard(item, key: ValueKey(item.uniqueId));
}
return const SizedBox.shrink();
});
},
),
);
}
Widget _buildMergedRecordCard(MergedTrainRecord mergedRecord) {
@@ -506,7 +629,8 @@ class HistoryScreenState extends State<HistoryScreen> {
return 10.0;
}
Widget _buildRecordCard(TrainRecord record, {bool isSubCard = false}) {
Widget _buildRecordCard(TrainRecord record,
{bool isSubCard = false, Key? key}) {
final isSelected = _selectedRecords.contains(record.uniqueId);
final isExpanded =
!isSubCard && (_expandedStates[record.uniqueId] ?? false);
@@ -514,7 +638,7 @@ class HistoryScreenState extends State<HistoryScreen> {
final GlobalKey itemKey = GlobalKey();
final Widget card = Card(
key: itemKey,
key: key ?? itemKey,
color: isSelected && _isEditMode
? const Color(0xFF2E2E2E)
: const Color(0xFF1E1E1E),
@@ -541,15 +665,23 @@ class HistoryScreenState extends State<HistoryScreen> {
});
} else if (!isSubCard) {
if (isExpanded) {
setState(() {
_expandedStates[record.uniqueId] = false;
_mapOptimalZoom.remove(record.uniqueId);
_mapCalculating.remove(record.uniqueId);
});
final shouldUpdate =
_expandedStates[record.uniqueId] == true ||
_mapOptimalZoom.containsKey(record.uniqueId) ||
_mapCalculating.containsKey(record.uniqueId);
if (shouldUpdate) {
setState(() {
_expandedStates[record.uniqueId] = false;
_mapOptimalZoom.remove(record.uniqueId);
_mapCalculating.remove(record.uniqueId);
});
}
} else {
setState(() {
_expandedStates[record.uniqueId] = true;
});
if (_expandedStates[record.uniqueId] != true) {
setState(() {
_expandedStates[record.uniqueId] = true;
});
}
}
}
},
@@ -568,12 +700,13 @@ class HistoryScreenState extends State<HistoryScreen> {
_buildRecordHeader(record),
_buildPositionAndSpeed(record),
_buildLocoInfo(record),
if (isExpanded) _buildExpandedContent(record)
if (isExpanded) _buildExpandedContent(record),
]))));
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_itemHeightCache <= 0 && itemKey.currentContext != null) {
final RenderBox renderBox = itemKey.currentContext!.findRenderObject() as RenderBox;
final RenderBox renderBox =
itemKey.currentContext!.findRenderObject() as RenderBox;
final double realHeight = renderBox.size.height;
if (realHeight > 0) {
setState(() {
@@ -588,8 +721,6 @@ class HistoryScreenState extends State<HistoryScreen> {
Widget _buildRecordHeader(TrainRecord record, {bool isMerged = false}) {
final trainType = record.trainType;
final trainDisplay =
record.fullTrainNumber.isEmpty ? "未知列车" : record.fullTrainNumber;
String formattedLocoInfo = "";
if (record.locoType.isNotEmpty && record.loco.isNotEmpty) {
final shortLoco = record.loco.length > 5
@@ -601,6 +732,22 @@ class HistoryScreenState extends State<HistoryScreen> {
} else if (record.loco.isNotEmpty) {
formattedLocoInfo = record.loco;
}
if (record.fullTrainNumber.isEmpty && formattedLocoInfo.isEmpty) {
return Text(
(record.time == "<NUL>" || record.time.isEmpty)
? record.receivedTimestamp.toString().split(".")[0]
: record.time.split("\n")[0],
style: const TextStyle(fontSize: 11, color: Colors.grey),
overflow: TextOverflow.ellipsis);
}
final hasTrainNumber = record.fullTrainNumber.isNotEmpty;
final hasDirection = record.direction == 1 || record.direction == 3;
final hasLocoInfo =
formattedLocoInfo.isNotEmpty && formattedLocoInfo != "<NUL>";
final shouldShowTrainRow = hasTrainNumber || hasDirection || hasLocoInfo;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Flexible(
@@ -616,43 +763,47 @@ class HistoryScreenState extends State<HistoryScreen> {
style: const TextStyle(fontSize: 11, color: Colors.grey),
overflow: TextOverflow.ellipsis))
]),
const SizedBox(height: 2),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Text(trainDisplay,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white),
overflow: TextOverflow.ellipsis)),
const SizedBox(width: 6),
if (record.direction == 1 || record.direction == 3)
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(2)),
child: Center(
child: Text(record.direction == 1 ? "" : "",
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.black))))
])),
if (formattedLocoInfo.isNotEmpty && formattedLocoInfo != "<NUL>")
Text(formattedLocoInfo,
style: const TextStyle(fontSize: 14, color: Colors.white70))
]),
const SizedBox(height: 2)
if (shouldShowTrainRow) ...[
const SizedBox(height: 2),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (hasTrainNumber)
Flexible(
child: Text(record.fullTrainNumber,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white),
overflow: TextOverflow.ellipsis)),
if (hasTrainNumber && hasDirection)
const SizedBox(width: 6),
if (hasDirection)
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(2)),
child: Center(
child: Text(record.direction == 1 ? "" : "",
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.black))))
])),
if (hasLocoInfo)
Text(formattedLocoInfo,
style: const TextStyle(fontSize: 14, color: Colors.white70))
]),
const SizedBox(height: 2)
]
]);
}

View File

@@ -397,7 +397,7 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
selectedIndex: _currentIndex,
onDestinationSelected: (index) {
if (_currentIndex == 2 && index == 0) {
_historyScreenKey.currentState?.loadRecords();
_historyScreenKey.currentState?.reloadRecords();
}
setState(() {
if (_isHistoryEditMode) _isHistoryEditMode = false;

View File

@@ -32,6 +32,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _notificationsEnabled = true;
int _recordCount = 0;
bool _mergeRecordsEnabled = false;
bool _hideTimeOnlyRecords = false;
GroupBy _groupBy = GroupBy.trainAndLoco;
TimeWindow _timeWindow = TimeWindow.unlimited;
@@ -61,6 +62,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
(settingsMap['backgroundServiceEnabled'] ?? 0) == 1;
_notificationsEnabled = (settingsMap['notificationEnabled'] ?? 1) == 1;
_mergeRecordsEnabled = settings.enabled;
_hideTimeOnlyRecords = (settingsMap['hideTimeOnlyRecords'] ?? 0) == 1;
_groupBy = settings.groupBy;
_timeWindow = settings.timeWindow;
});
@@ -82,6 +84,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
'backgroundServiceEnabled': _backgroundServiceEnabled ? 1 : 0,
'notificationEnabled': _notificationsEnabled ? 1 : 0,
'mergeRecordsEnabled': _mergeRecordsEnabled ? 1 : 0,
'hideTimeOnlyRecords': _hideTimeOnlyRecords ? 1 : 0,
'groupBy': _groupBy.name,
'timeWindow': _timeWindow.name,
});
@@ -236,6 +239,29 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('隐藏只有时间有效的记录', style: AppTheme.bodyLarge),
Text('不显示只有时间信息的记录', style: AppTheme.caption),
],
),
Switch(
value: _hideTimeOnlyRecords,
onChanged: (value) {
setState(() {
_hideTimeOnlyRecords = value;
});
_saveSettings();
},
activeColor: Theme.of(context).colorScheme.primary,
),
],
),
],
),
),

View File

@@ -13,7 +13,7 @@ class DatabaseService {
DatabaseService._internal();
static const String _databaseName = 'train_database';
static const _databaseVersion = 1;
static const _databaseVersion = 2;
static const String trainRecordsTable = 'train_records';
static const String appSettingsTable = 'app_settings';
@@ -34,9 +34,17 @@ class DatabaseService {
path,
version: _databaseVersion,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
);
}
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
if (oldVersion < 2) {
await db.execute(
'ALTER TABLE $appSettingsTable ADD COLUMN hideTimeOnlyRecords INTEGER NOT NULL DEFAULT 0');
}
}
Future<void> _onCreate(Database db, int version) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS $trainRecordsTable (
@@ -79,6 +87,7 @@ class DatabaseService {
backgroundServiceEnabled INTEGER NOT NULL DEFAULT 0,
notificationEnabled INTEGER NOT NULL DEFAULT 0,
mergeRecordsEnabled INTEGER NOT NULL DEFAULT 0,
hideTimeOnlyRecords INTEGER NOT NULL DEFAULT 0,
groupBy TEXT NOT NULL DEFAULT 'trainAndLoco',
timeWindow TEXT NOT NULL DEFAULT 'unlimited'
)
@@ -102,6 +111,7 @@ class DatabaseService {
'backgroundServiceEnabled': 0,
'notificationEnabled': 0,
'mergeRecordsEnabled': 0,
'hideTimeOnlyRecords': 0,
'groupBy': 'trainAndLoco',
'timeWindow': 'unlimited',
});

View File

@@ -5,7 +5,7 @@ class MergeService {
static String? _generateGroupKey(TrainRecord record, GroupBy groupBy) {
final train = record.train.trim();
final loco = record.loco.trim();
final hasTrain = train.isNotEmpty && train != "<NUL>";
final hasTrain = train.isNotEmpty && train != "<NUL>" && !train.contains("-----");
final hasLoco = loco.isNotEmpty && loco != "<NUL>";
switch (groupBy) {

View File

@@ -920,6 +920,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.28.0"
scrollview_observer:
dependency: "direct main"
description:
name: scrollview_observer
sha256: c2f713509f18f88f637b2084b47a90c91fb1ef066d5d82d2cf3194d8509dc6ab
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.26.2"
share_plus:
dependency: "direct main"
description:

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 0.2.0-flutter+20 # versionName: 0.2.0-flutter, versionCode: 3
version: 0.3.0-flutter+30
environment:
sdk: ^3.5.4
@@ -53,6 +53,7 @@ dependencies:
package_info_plus: ^8.1.2
msix: ^3.16.12
flutter_background_service: ^5.1.0
scrollview_observer: ^1.20.0
dev_dependencies:
flutter_test: