This commit is contained in:
Nedifinita
2025-08-29 13:28:14 +08:00
commit 25f66000cb
148 changed files with 10964 additions and 0 deletions

35
lib/main.dart Normal file
View File

@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:lbjconsole/screens/main_screen.dart';
import 'package:lbjconsole/util/train_type_util.dart';
import 'package:lbjconsole/util/loco_info_util.dart';
import 'package:lbjconsole/util/loco_type_util.dart';
import 'package:lbjconsole/services/loco_type_service.dart';
import 'package:lbjconsole/services/database_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Future.wait([
TrainTypeUtil.initialize(),
LocoInfoUtil.initialize(),
LocoTypeService().initialize(),
]);
runApp(const LBJReceiverApp());
}
class LBJReceiverApp extends StatelessWidget {
const LBJReceiverApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'LBJ Console',
debugShowCheckedModeBanner: false,
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
themeMode: ThemeMode.dark,
home: const MainScreen(),
);
}
}

View File

@@ -0,0 +1,60 @@
import 'package:lbjconsole/models/train_record.dart';
class MergedTrainRecord {
final String groupKey;
final List<TrainRecord> records;
final TrainRecord latestRecord;
MergedTrainRecord({
required this.groupKey,
required this.records,
required this.latestRecord,
});
int get recordCount => records.length;
}
class MergeSettings {
final bool enabled;
final GroupBy groupBy;
final TimeWindow timeWindow;
MergeSettings({
this.enabled = true,
this.groupBy = GroupBy.trainAndLoco,
this.timeWindow = TimeWindow.unlimited,
});
factory MergeSettings.fromMap(Map<String, dynamic> map) {
return MergeSettings(
enabled: (map['mergeRecordsEnabled'] ?? 0) == 1,
groupBy: GroupBy.values.firstWhere(
(e) => e.name == map['groupBy'],
orElse: () => GroupBy.trainAndLoco,
),
timeWindow: TimeWindow.values.firstWhere(
(e) => e.name == map['timeWindow'],
orElse: () => TimeWindow.unlimited,
),
);
}
}
enum GroupBy {
trainOnly,
locoOnly,
trainOrLoco,
trainAndLoco,
}
enum TimeWindow {
oneHour(Duration(hours: 1)),
twoHours(Duration(hours: 2)),
sixHours(Duration(hours: 6)),
twelveHours(Duration(hours: 12)),
oneDay(Duration(days: 1)),
unlimited(null);
final Duration? duration;
const TimeWindow(this.duration);
}

View File

@@ -0,0 +1,301 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:lbjconsole/util/train_type_util.dart';
import 'package:lbjconsole/util/loco_info_util.dart';
class TrainRecord {
final String uniqueId;
final DateTime timestamp;
final DateTime receivedTimestamp;
final String train;
final int direction;
final String speed;
final String position;
final String time;
final String loco;
final String locoType;
final String lbjClass;
final String route;
final String positionInfo;
final double rssi;
TrainRecord({
required this.uniqueId,
required this.timestamp,
required this.receivedTimestamp,
required this.train,
required this.direction,
required this.speed,
required this.position,
required this.time,
required this.loco,
required this.locoType,
required this.lbjClass,
required this.route,
required this.positionInfo,
required this.rssi,
});
factory TrainRecord.fromJson(Map<String, dynamic> json) {
return TrainRecord(
uniqueId: json['uniqueId'] ?? json['unique_id'] ?? '',
timestamp: DateTime.fromMillisecondsSinceEpoch(json['timestamp'] ?? 0),
receivedTimestamp: DateTime.fromMillisecondsSinceEpoch(
json['receivedTimestamp'] ?? json['received_timestamp'] ?? 0),
train: json['train'] ?? '',
direction: json['direction'] ?? json['dir'] ?? 0,
speed: json['speed'] ?? '',
position: json['position'] ?? json['pos'] ?? '',
time: json['time'] ?? '',
loco: json['loco'] ?? '',
locoType: json['locoType'] ?? json['loco_type'] ?? '',
lbjClass: json['lbjClass'] ?? json['lbj_class'] ?? '',
route: json['route'] ?? '',
positionInfo: json['positionInfo'] ?? json['position_info'] ?? '',
rssi: (json['rssi'] ?? 0.0).toDouble(),
);
}
factory TrainRecord.fromJsonString(String jsonString) {
final json = jsonDecode(jsonString);
return TrainRecord.fromJson(json);
}
Map<String, dynamic> toJson() {
return {
'uniqueId': uniqueId,
'timestamp': timestamp.millisecondsSinceEpoch,
'receivedTimestamp': receivedTimestamp.millisecondsSinceEpoch,
'train': train,
'direction': direction,
'speed': speed,
'position': position,
'time': time,
'loco': loco,
'loco_type': locoType,
'lbj_class': lbjClass,
'route': route,
'position_info': positionInfo,
'rssi': rssi,
};
}
Map<String, dynamic> toDatabaseJson() {
return {
'uniqueId': uniqueId,
'timestamp': timestamp.millisecondsSinceEpoch,
'receivedTimestamp': receivedTimestamp.millisecondsSinceEpoch,
'train': train,
'direction': direction,
'speed': speed,
'position': position,
'time': time,
'loco': loco,
'locoType': locoType,
'lbjClass': lbjClass,
'route': route,
'positionInfo': positionInfo,
'rssi': rssi,
};
}
factory TrainRecord.fromDatabaseJson(Map<String, dynamic> json) {
return TrainRecord(
uniqueId: json['uniqueId']?.toString() ?? '',
timestamp:
DateTime.fromMillisecondsSinceEpoch(json['timestamp'] as int? ?? 0),
receivedTimestamp: DateTime.fromMillisecondsSinceEpoch(
json['receivedTimestamp'] as int? ?? 0),
train: json['train']?.toString() ?? '',
direction: json['direction'] as int? ?? 0,
speed: json['speed']?.toString() ?? '',
position: json['position']?.toString() ?? '',
time: json['time']?.toString() ?? '',
loco: json['loco']?.toString() ?? '',
locoType: json['locoType']?.toString() ?? '',
lbjClass: json['lbjClass']?.toString() ?? '',
route: json['route']?.toString() ?? '',
positionInfo: json['positionInfo']?.toString() ?? '',
rssi: (json['rssi'] as num?)?.toDouble() ?? 0.0,
);
}
String get directionText {
switch (direction) {
case 0:
return '上行';
case 1:
return '下行';
default:
return '未知';
}
}
String get locoTypeText {
if (locoType.isEmpty) return '未知';
return locoType;
}
String get trainType {
final lbjClassValue = lbjClass.isEmpty ? "NA" : lbjClass;
return TrainTypeUtil.getTrainType(lbjClassValue, train) ?? '未知';
}
String? get locoInfo {
return LocoInfoUtil.getLocoInfoDisplay(locoType, train);
}
String get fullTrainNumber {
final lbjClassValue = lbjClass.trim();
final trainValue = train.trim();
if (trainValue == "<NUL>") {
return "";
}
if (lbjClassValue.isEmpty || lbjClassValue == "NA") {
return trainValue;
} else {
return "$lbjClassValue$trainValue";
}
}
String get lbjClassText {
if (lbjClass.isEmpty) return '未知';
return lbjClass;
}
double get speedValue {
try {
return double.parse(speed.replaceAll(RegExp(r'[^\d.]'), ''));
} catch (e) {
return 0.0;
}
}
String get speedUnit {
if (speed.contains('km/h')) return 'km/h';
if (speed.contains('m/s')) return 'm/s';
return '';
}
String get formattedTime {
return '${timestamp.hour.toString().padLeft(2, '0')}:${timestamp.minute.toString().padLeft(2, '0')}:${timestamp.second.toString().padLeft(2, '0')}';
}
String get formattedDate {
return '${timestamp.year}-${timestamp.month.toString().padLeft(2, '0')}-${timestamp.day.toString().padLeft(2, '0')}';
}
String get relativeTime {
final now = DateTime.now();
final difference = now.difference(timestamp);
if (difference.inMinutes < 1) {
return '刚刚';
} else if (difference.inHours < 1) {
return '${difference.inMinutes}分钟前';
} else if (difference.inDays < 1) {
return '${difference.inHours}小时前';
} else if (difference.inDays < 7) {
return '${difference.inDays}天前';
} else {
return formattedDate;
}
}
String get rssiDescription {
if (rssi > -50) return '';
if (rssi > -70) return '';
if (rssi > -90) return '';
return '无信号';
}
Color get rssiColor {
if (rssi > -50) return Colors.green;
if (rssi > -70) return Colors.orange;
if (rssi > -90) return Colors.red;
return Colors.grey;
}
Map<String, dynamic> toMap() {
return {
'uniqueId': uniqueId,
'timestamp': timestamp.millisecondsSinceEpoch,
'receivedTimestamp': receivedTimestamp.millisecondsSinceEpoch,
'train': train,
'direction': direction,
'speed': speed,
'position': position,
'time': time,
'loco': loco,
'locoType': locoType,
'lbjClass': lbjClass,
'route': route,
'positionInfo': positionInfo,
'rssi': rssi,
};
}
Map<String, double> getCoordinates() {
final parts = position.split(',');
if (parts.length >= 2) {
try {
final lat = double.parse(parts[0].trim());
final lng = double.parse(parts[1].trim());
return {'lat': lat, 'lng': lng};
} catch (e) {
return {'lat': 0.0, 'lng': 0.0};
}
}
return {'lat': 0.0, 'lng': 0.0};
}
TrainRecord copyWith({
String? uniqueId,
DateTime? timestamp,
DateTime? receivedTimestamp,
String? train,
int? direction,
String? speed,
String? position,
String? time,
String? loco,
String? locoType,
String? lbjClass,
String? route,
String? positionInfo,
double? rssi,
}) {
return TrainRecord(
uniqueId: uniqueId ?? this.uniqueId,
timestamp: timestamp ?? this.timestamp,
receivedTimestamp: receivedTimestamp ?? this.receivedTimestamp,
train: train ?? this.train,
direction: direction ?? this.direction,
speed: speed ?? this.speed,
position: position ?? this.position,
time: time ?? this.time,
loco: loco ?? this.loco,
locoType: locoType ?? this.locoType,
lbjClass: lbjClass ?? this.lbjClass,
route: route ?? this.route,
positionInfo: positionInfo ?? this.positionInfo,
rssi: rssi ?? this.rssi,
);
}
@override
String toString() {
return 'TrainRecord(uniqueId: $uniqueId, train: $train, direction: $direction, speed: $speed, position: $position)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is TrainRecord && other.uniqueId == uniqueId;
}
@override
int get hashCode => uniqueId.hashCode;
}

View File

@@ -0,0 +1,566 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:lbjconsole/models/merged_record.dart';
import 'package:lbjconsole/services/database_service.dart';
import 'package:lbjconsole/models/train_record.dart';
import 'package:lbjconsole/services/merge_service.dart';
class HistoryScreen extends StatefulWidget {
final Function(bool isEditing) onEditModeChanged;
final Function() onSelectionChanged;
const HistoryScreen({
super.key,
required this.onEditModeChanged,
required this.onSelectionChanged,
});
@override
HistoryScreenState createState() => HistoryScreenState();
}
class HistoryScreenState extends State<HistoryScreen> {
final List<Object> _displayItems = [];
bool _isLoading = true;
bool _isEditMode = false;
final Set<String> _selectedRecords = {};
final Map<String, bool> _expandedStates = {};
final ScrollController _scrollController = ScrollController();
bool _isAtTop = true;
MergeSettings _mergeSettings = MergeSettings();
int getSelectedCount() => _selectedRecords.length;
Set<String> getSelectedRecordIds() => _selectedRecords;
List<Object> getDisplayItems() => _displayItems;
void clearSelection() => setState(() => _selectedRecords.clear());
void setEditMode(bool isEditing) {
setState(() {
_isEditMode = isEditing;
widget.onEditModeChanged(isEditing);
if (!isEditing) {
_selectedRecords.clear();
}
});
}
@override
void initState() {
super.initState();
loadRecords();
_scrollController.addListener(() {
if (_scrollController.position.atEdge) {
if (_scrollController.position.pixels == 0) {
if (!_isAtTop) setState(() => _isAtTop = true);
}
} else {
if (_isAtTop) setState(() => _isAtTop = false);
}
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
Future<void> loadRecords({bool scrollToTop = true}) async {
if (mounted) setState(() => _isLoading = true);
try {
final allRecords = await DatabaseService.instance.getAllRecords();
final settingsMap = await DatabaseService.instance.getAllSettings() ?? {};
_mergeSettings = MergeSettings.fromMap(settingsMap);
final items = MergeService.getMixedList(allRecords, _mergeSettings);
if (mounted) {
setState(() {
_displayItems.clear();
_displayItems.addAll(items);
_isLoading = false;
});
if (scrollToTop && (_isAtTop) && _scrollController.hasClients) {
_scrollController.animateTo(0.0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut);
}
}
} catch (e) {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_displayItems.isEmpty) {
return const Center(
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.history, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text('暂无记录', style: TextStyle(color: Colors.white, fontSize: 18))
]));
}
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16.0),
itemCount: _displayItems.length,
itemBuilder: (context, index) {
final item = _displayItems[index];
if (item is MergedTrainRecord) {
return _buildMergedRecordCard(item);
} else if (item is TrainRecord) {
return _buildRecordCard(item);
}
return const SizedBox.shrink();
});
}
Widget _buildMergedRecordCard(MergedTrainRecord mergedRecord) {
final bool isSelected =
mergedRecord.records.any((r) => _selectedRecords.contains(r.uniqueId));
final isExpanded = _expandedStates[mergedRecord.groupKey] ?? false;
return Card(
color: isSelected && _isEditMode
? const Color(0xFF2E2E2E)
: const Color(0xFF1E1E1E),
elevation: 1,
margin: const EdgeInsets.only(bottom: 8.0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
side: BorderSide(
color: isSelected && _isEditMode
? Colors.blue
: Colors.transparent,
width: 2.0)),
child: InkWell(
borderRadius: BorderRadius.circular(8.0),
onTap: () {
if (_isEditMode) {
setState(() {
final allIdsInGroup =
mergedRecord.records.map((r) => r.uniqueId).toSet();
if (isSelected) {
_selectedRecords.removeAll(allIdsInGroup);
} else {
_selectedRecords.addAll(allIdsInGroup);
}
widget.onSelectionChanged();
});
} else {
setState(
() => _expandedStates[mergedRecord.groupKey] = !isExpanded);
}
},
onLongPress: () {
if (!_isEditMode) setEditMode(true);
setState(() {
final allIdsInGroup =
mergedRecord.records.map((r) => r.uniqueId).toSet();
_selectedRecords.addAll(allIdsInGroup);
widget.onSelectionChanged();
});
},
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildRecordHeader(mergedRecord.latestRecord,
isMerged: true),
_buildPositionAndSpeed(mergedRecord.latestRecord),
_buildLocoInfo(mergedRecord.latestRecord),
if (isExpanded) _buildMergedExpandedContent(mergedRecord)
]))));
}
Widget _buildMergedExpandedContent(MergedTrainRecord mergedRecord) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildExpandedMapForAll(mergedRecord.records),
const Divider(color: Colors.white24, height: 24),
...mergedRecord.records.map((record) => _buildSubRecordItem(
record, mergedRecord.latestRecord, _mergeSettings.groupBy)),
],
);
}
Widget _buildSubRecordItem(
TrainRecord record, TrainRecord latest, GroupBy groupBy) {
String differingInfo = _getDifferingInfo(record, latest, groupBy);
String locationInfo = _getLocationInfo(record);
return Padding(
padding: const EdgeInsets.only(bottom: 8.0, top: 4.0),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
record.receivedTimestamp.toString().split('.')[0],
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
if (differingInfo.isNotEmpty)
Text(
differingInfo,
style:
const TextStyle(color: Color(0xFF81D4FA), fontSize: 12),
),
],
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
locationInfo,
style: const TextStyle(color: Colors.white70, fontSize: 14),
overflow: TextOverflow.ellipsis,
),
),
Text(
record.speed.isNotEmpty ? "${record.speed} km/h" : "",
style: const TextStyle(color: Colors.white70, fontSize: 14),
),
],
),
],
),
);
}
String _getDifferingInfo(
TrainRecord record, TrainRecord latest, GroupBy groupBy) {
final train = record.train.trim();
final loco = record.loco.trim();
final latestTrain = latest.train.trim();
final latestLoco = latest.loco.trim();
switch (groupBy) {
case GroupBy.trainOnly:
return loco != latestLoco && loco.isNotEmpty ? "机车: $loco" : "";
case GroupBy.locoOnly:
return train != latestTrain && train.isNotEmpty ? "车次: $train" : "";
case GroupBy.trainOrLoco:
if (train.isNotEmpty && train != latestTrain) return "车次: $train";
if (loco.isNotEmpty && loco != latestLoco) return "机车: $loco";
return "";
case GroupBy.trainAndLoco:
return "";
}
}
String _getLocationInfo(TrainRecord record) {
List<String> parts = [];
if (record.route.isNotEmpty && record.route != "<NUL>")
parts.add(record.route);
if (record.direction != 0) parts.add(record.direction == 1 ? "" : "");
if (record.position.isNotEmpty && record.position != "<NUL>")
parts.add("${record.position}K");
return parts.join(' ');
}
Widget _buildExpandedMapForAll(List<TrainRecord> records) {
final positions = records
.map((record) => _parsePosition(record.positionInfo))
.whereType<LatLng>()
.toList();
if (positions.isEmpty) return const SizedBox.shrink();
final bounds = LatLngBounds.fromPoints(positions);
return Column(children: [
const SizedBox(height: 8),
Container(
height: 220,
margin: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), color: Colors.grey[900]),
child: FlutterMap(
options: MapOptions(
initialCenter: bounds.center,
initialZoom: 10,
minZoom: 5,
maxZoom: 18,
cameraConstraint: CameraConstraint.contain(bounds: bounds)),
children: [
TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'org.noxylva.lbjconsole'),
MarkerLayer(
markers: positions
.map((pos) => Marker(
point: pos,
width: 40,
height: 40,
child: Container(
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.8),
shape: BoxShape.circle,
border: Border.all(
color: Colors.white, width: 2)),
child: const Icon(Icons.train,
color: Colors.white, size: 20))))
.toList())
]))
]);
}
Widget _buildRecordCard(TrainRecord record, {bool isSubCard = false}) {
final isSelected = _selectedRecords.contains(record.uniqueId);
final isExpanded =
!isSubCard && (_expandedStates[record.uniqueId] ?? false);
return Card(
color: isSelected && _isEditMode
? const Color(0xFF2E2E2E)
: const Color(0xFF1E1E1E),
elevation: isSubCard ? 0 : 1,
margin: EdgeInsets.only(bottom: isSubCard ? 4.0 : 8.0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
side: BorderSide(
color: isSelected && _isEditMode
? Colors.blue
: Colors.transparent,
width: 2.0)),
child: InkWell(
borderRadius: BorderRadius.circular(8.0),
onTap: () {
if (_isEditMode) {
setState(() {
if (isSelected) {
_selectedRecords.remove(record.uniqueId);
} else {
_selectedRecords.add(record.uniqueId);
}
widget.onSelectionChanged();
});
} else if (!isSubCard) {
setState(() => _expandedStates[record.uniqueId] = !isExpanded);
}
},
onLongPress: () {
if (!_isEditMode) setEditMode(true);
setState(() {
_selectedRecords.add(record.uniqueId);
widget.onSelectionChanged();
});
},
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildRecordHeader(record),
_buildPositionAndSpeed(record),
_buildLocoInfo(record),
if (isExpanded) _buildExpandedContent(record)
]))));
}
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
? record.loco.substring(record.loco.length - 5)
: record.loco;
formattedLocoInfo = "${record.locoType}-$shortLoco";
} else if (record.locoType.isNotEmpty) {
formattedLocoInfo = record.locoType;
} else if (record.loco.isNotEmpty) {
formattedLocoInfo = record.loco;
}
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Flexible(
child: Text(
(record.time == "<NUL>" || record.time.isEmpty)
? record.receivedTimestamp.toString().split(".")[0]
: record.time.split("\n")[0],
style: const TextStyle(fontSize: 12, color: Colors.grey),
overflow: TextOverflow.ellipsis)),
if (trainType.isNotEmpty)
Flexible(
child: Text(trainType,
style: const TextStyle(fontSize: 12, 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)
]);
}
Widget _buildLocoInfo(TrainRecord record) {
final locoInfo = record.locoInfo;
if (locoInfo == null || locoInfo.isEmpty) return const SizedBox.shrink();
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const SizedBox(height: 4),
Text(locoInfo,
style: const TextStyle(fontSize: 14, color: Colors.white),
maxLines: 1,
overflow: TextOverflow.ellipsis)
]);
}
Widget _buildPositionAndSpeed(TrainRecord record) {
final routeStr = record.route.trim();
final position = record.position.trim();
final speed = record.speed.trim();
final isValidRoute = routeStr.isNotEmpty &&
!routeStr.runes.every((r) => r == '*'.runes.first);
final isValidPosition = position.isNotEmpty &&
!position.runes
.every((r) => r == '-'.runes.first || r == '.'.runes.first) &&
position != "<NUL>";
final isValidSpeed = speed.isNotEmpty &&
!speed.runes
.every((r) => r == '*'.runes.first || r == '-'.runes.first) &&
speed != "NUL" &&
speed != "<NUL>";
if (!isValidRoute && !isValidPosition && !isValidSpeed)
return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 4.0),
child:
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
if (isValidRoute || isValidPosition)
Expanded(
child: Row(children: [
if (isValidRoute)
Flexible(
child: Text(routeStr,
style:
const TextStyle(fontSize: 16, color: Colors.white),
overflow: TextOverflow.ellipsis)),
if (isValidRoute && isValidPosition) const SizedBox(width: 4),
if (isValidPosition)
Flexible(
child: Text("$position K",
style:
const TextStyle(fontSize: 16, color: Colors.white),
overflow: TextOverflow.ellipsis))
])),
if (isValidSpeed)
Text("$speed km/h",
style: const TextStyle(fontSize: 16, color: Colors.white),
textAlign: TextAlign.right)
]));
}
Widget _buildExpandedContent(TrainRecord record) {
final position = _parsePosition(record.positionInfo);
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
if (position != null)
Column(children: [
const SizedBox(height: 8),
Container(
height: 220,
margin: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Colors.grey[900]),
child: FlutterMap(
options:
MapOptions(initialCenter: position, initialZoom: 15.0),
children: [
TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'org.noxylva.lbjconsole'),
MarkerLayer(markers: [
Marker(
point: position,
width: 40,
height: 40,
child: Container(
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Colors.white, width: 2)),
child: const Icon(Icons.train,
color: Colors.white, size: 20)))
])
]))
])
]);
}
LatLng? _parsePosition(String? positionInfo) {
if (positionInfo == null || positionInfo.isEmpty || positionInfo == '<NUL>')
return null;
try {
final parts = positionInfo.trim().split(RegExp(r'\s+'));
if (parts.length >= 2) {
final lat = _parseDmsCoordinate(parts[0]);
final lng = _parseDmsCoordinate(parts[1]);
if (lat != null &&
lng != null &&
(lat.abs() > 0.001 || lng.abs() > 0.001)) {
return LatLng(lat, lng);
}
}
} catch (e) {}
return null;
}
double? _parseDmsCoordinate(String dmsStr) {
try {
final degreeIndex = dmsStr.indexOf('°');
if (degreeIndex == -1) return null;
final degrees = double.tryParse(dmsStr.substring(0, degreeIndex));
if (degrees == null) return null;
final minuteIndex = dmsStr.indexOf('');
if (minuteIndex == -1) return degrees;
final minutes =
double.tryParse(dmsStr.substring(degreeIndex + 1, minuteIndex));
if (minutes == null) return degrees;
return degrees + (minutes / 60.0);
} catch (e) {
return null;
}
}
}

View File

@@ -0,0 +1,398 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:async';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:lbjconsole/models/merged_record.dart';
import 'package:lbjconsole/models/train_record.dart';
import 'package:lbjconsole/screens/history_screen.dart';
import 'package:lbjconsole/screens/map_screen.dart';
import 'package:lbjconsole/screens/settings_screen.dart';
import 'package:lbjconsole/services/ble_service.dart';
import 'package:lbjconsole/services/database_service.dart';
import 'package:lbjconsole/services/notification_service.dart';
import 'package:lbjconsole/themes/app_theme.dart';
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
@override
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
int _currentIndex = 0;
late final BLEService _bleService;
final NotificationService _notificationService = NotificationService();
StreamSubscription? _connectionSubscription;
StreamSubscription? _dataSubscription;
bool _isHistoryEditMode = false;
final GlobalKey<HistoryScreenState> _historyScreenKey =
GlobalKey<HistoryScreenState>();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_bleService = BLEService();
_bleService.initialize();
_initializeServices();
}
@override
void dispose() {
_connectionSubscription?.cancel();
_dataSubscription?.cancel();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_bleService.onAppResume();
}
}
Future<void> _initializeServices() async {
await _notificationService.initialize();
_connectionSubscription = _bleService.connectionStream.listen((_) {
if (mounted) setState(() {});
});
_dataSubscription = _bleService.dataStream.listen((record) {
_notificationService.showTrainNotification(record);
if (_historyScreenKey.currentState != null) {
_historyScreenKey.currentState!.loadRecords(scrollToTop: true);
}
});
}
void _showConnectionDialog() {
_bleService.setAutoConnectBlocked(true);
showDialog(
context: context,
barrierDismissible: true,
builder: (context) =>
_PixelPerfectBluetoothDialog(bleService: _bleService),
).then((_) {
_bleService.setAutoConnectBlocked(false);
if (!_bleService.isManualDisconnect) {
_bleService.ensureConnection();
}
});
}
AppBar _buildAppBar(BuildContext context) {
final historyState = _historyScreenKey.currentState;
final selectedCount = historyState?.getSelectedCount() ?? 0;
if (_currentIndex == 0 && _isHistoryEditMode) {
return AppBar(
backgroundColor: Theme.of(context).primaryColor,
leading: IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: _handleHistoryCancelSelection,
),
title: Text(
'已选择 $selectedCount',
style: const TextStyle(color: Colors.white, fontSize: 18),
),
actions: [
IconButton(
icon: const Icon(Icons.delete, color: Colors.white),
onPressed: selectedCount > 0 ? _handleHistoryDeleteSelected : null,
),
],
);
}
return AppBar(
backgroundColor: AppTheme.primaryBlack,
elevation: 0,
title: Text(
['列车记录', '位置地图', '设置'][_currentIndex],
style: const TextStyle(
color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold),
),
centerTitle: false,
actions: [
Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: _bleService.isConnected ? Colors.green : Colors.red,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(_bleService.deviceStatus,
style: const TextStyle(color: Colors.white70)),
IconButton(
icon: const Icon(Icons.bluetooth, color: Colors.white),
onPressed: _showConnectionDialog,
),
],
),
],
);
}
void _handleHistoryEditModeChanged(bool isEditing) {
setState(() {
_isHistoryEditMode = isEditing;
if (!isEditing) {
_historyScreenKey.currentState?.clearSelection();
}
});
}
void _handleSelectionChanged() {
if (_isHistoryEditMode &&
(_historyScreenKey.currentState?.getSelectedCount() ?? 0) == 0) {
_handleHistoryCancelSelection();
} else {
setState(() {});
}
}
void _handleHistoryCancelSelection() {
_historyScreenKey.currentState?.setEditMode(false);
}
Future<void> _handleHistoryDeleteSelected() async {
final historyState = _historyScreenKey.currentState;
if (historyState == null || historyState.getSelectedCount() == 0) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认删除'),
content: Text('确定要删除选中的 ${historyState.getSelectedCount()} 条记录吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消')),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red, foregroundColor: Colors.white),
child: const Text('删除'),
),
],
),
);
if (confirmed == true) {
final idsToDelete = historyState.getSelectedRecordIds().toList();
await DatabaseService.instance.deleteRecords(idsToDelete);
historyState.setEditMode(false);
historyState.loadRecords(scrollToTop: false);
}
}
@override
Widget build(BuildContext context) {
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
systemNavigationBarColor: AppTheme.primaryBlack,
statusBarIconBrightness: Brightness.light,
systemNavigationBarIconBrightness: Brightness.light,
));
final pages = [
HistoryScreen(
key: _historyScreenKey,
onEditModeChanged: _handleHistoryEditModeChanged,
onSelectionChanged: _handleSelectionChanged,
),
const MapScreen(),
const SettingsScreen(),
];
return Scaffold(
backgroundColor: AppTheme.primaryBlack,
appBar: _buildAppBar(context),
body: IndexedStack(
index: _currentIndex,
children: pages,
),
bottomNavigationBar: NavigationBar(
backgroundColor: AppTheme.secondaryBlack,
indicatorColor: AppTheme.accentBlue.withOpacity(0.2),
selectedIndex: _currentIndex,
onDestinationSelected: (index) {
if (_currentIndex == 2 && index == 0) {
_historyScreenKey.currentState?.loadRecords();
}
setState(() {
if (_isHistoryEditMode) _isHistoryEditMode = false;
_currentIndex = index;
});
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.directions_railway), label: '列车记录'),
NavigationDestination(icon: Icon(Icons.location_on), label: '位置地图'),
NavigationDestination(icon: Icon(Icons.settings), label: '设置'),
],
),
);
}
}
enum _ScanState { initial, scanning, finished }
class _PixelPerfectBluetoothDialog extends StatefulWidget {
final BLEService bleService;
const _PixelPerfectBluetoothDialog({required this.bleService});
@override
State<_PixelPerfectBluetoothDialog> createState() =>
_PixelPerfectBluetoothDialogState();
}
class _PixelPerfectBluetoothDialogState
extends State<_PixelPerfectBluetoothDialog> {
List<BluetoothDevice> _devices = [];
_ScanState _scanState = _ScanState.initial;
StreamSubscription? _connectionSubscription;
@override
void initState() {
super.initState();
_connectionSubscription = widget.bleService.connectionStream.listen((_) {
if (mounted) setState(() {});
});
if (!widget.bleService.isConnected) {
_startScan();
}
}
@override
void dispose() {
_connectionSubscription?.cancel();
super.dispose();
}
Future<void> _startScan() async {
if (_scanState == _ScanState.scanning) return;
if (mounted)
setState(() {
_devices.clear();
_scanState = _ScanState.scanning;
});
await widget.bleService.startScan(
timeout: const Duration(seconds: 8),
onScanResults: (devices) {
if (mounted) setState(() => _devices = devices);
},
);
if (mounted) setState(() => _scanState = _ScanState.finished);
}
Future<void> _connectToDevice(BluetoothDevice device) async {
Navigator.pop(context);
await widget.bleService.connectManually(device);
}
Future<void> _disconnect() async {
Navigator.pop(context);
await widget.bleService.disconnect();
}
@override
Widget build(BuildContext context) {
final isConnected = widget.bleService.isConnected;
return AlertDialog(
title: const Text('蓝牙设备'),
content: SizedBox(
width: double.maxFinite,
child: SingleChildScrollView(
child: isConnected
? _buildConnectedView(context, widget.bleService.connectedDevice)
: _buildDisconnectedView(context),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('关闭'),
),
],
);
}
Widget _buildConnectedView(BuildContext context, BluetoothDevice? device) {
return Column(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.bluetooth_connected, size: 48, color: Colors.green),
const SizedBox(height: 16),
Text('设备已连接',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text(device?.platformName ?? '未知设备', textAlign: TextAlign.center),
Text(device?.remoteId.str ?? '',
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _disconnect,
icon: const Icon(Icons.bluetooth_disabled),
label: const Text('断开连接'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red, foregroundColor: Colors.white))
]);
}
Widget _buildDisconnectedView(BuildContext context) {
return Column(mainAxisSize: MainAxisSize.min, children: [
ElevatedButton.icon(
onPressed: _scanState == _ScanState.scanning ? null : _startScan,
icon: _scanState == _ScanState.scanning
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white))
: const Icon(Icons.search),
label: Text(_scanState == _ScanState.scanning ? '扫描中...' : '扫描设备'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(double.infinity, 40))),
const SizedBox(height: 16),
if (_scanState == _ScanState.finished && _devices.isNotEmpty)
_buildDeviceListView()
]);
}
Widget _buildDeviceListView() {
return SizedBox(
height: 200,
child: ListView.builder(
shrinkWrap: true,
itemCount: _devices.length,
itemBuilder: (context, index) {
final device = _devices[index];
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
leading: const Icon(Icons.bluetooth),
title: Text(device.platformName.isNotEmpty
? device.platformName
: '未知设备'),
subtitle: Text(device.remoteId.str),
onTap: () => _connectToDevice(device),
),
);
},
),
);
}
}

640
lib/screens/map_screen.dart Normal file
View File

@@ -0,0 +1,640 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:geolocator/geolocator.dart';
import 'package:lbjconsole/services/database_service.dart';
import 'package:lbjconsole/models/train_record.dart';
class MapScreen extends StatefulWidget {
const MapScreen({super.key});
@override
State<MapScreen> createState() => _MapScreenState();
}
class _MapScreenState extends State<MapScreen> {
final MapController _mapController = MapController();
final List<TrainRecord> _trainRecords = [];
bool _isLoading = true;
bool _railwayLayerVisible = true;
LatLng? _currentLocation;
LatLng? _lastTrainLocation;
LatLng? _userLocation;
double _currentZoom = 12.0;
double _currentRotation = 0.0;
bool _isMapInitialized = false;
bool _isFollowingLocation = false;
bool _isLocationPermissionGranted = false;
static const LatLng _defaultPosition = LatLng(39.9042, 116.4074);
@override
void initState() {
super.initState();
_initializeMap();
_loadTrainRecords();
_loadSettings();
_requestLocationPermission();
}
@override
void dispose() {
_saveSettings();
super.dispose();
}
Future<void> _initializeMap() async {}
Future<void> _requestLocationPermission() async {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
return;
}
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
if (permission == LocationPermission.deniedForever) {
return;
}
setState(() {
_isLocationPermissionGranted = true;
});
_getCurrentLocation();
}
Future<void> _getCurrentLocation() async {
try {
Position position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
);
setState(() {
_userLocation = LatLng(position.latitude, position.longitude);
});
if (!_isMapInitialized && _userLocation != null) {
_mapController.move(_userLocation!, _currentZoom);
}
} catch (e) {}
}
Future<void> _loadSettings() async {
try {
final settings = await DatabaseService.instance.getAllSettings();
if (settings != null) {
setState(() {
_railwayLayerVisible =
(settings['mapRailwayLayerVisible'] as int?) == 1;
_currentZoom = (settings['mapZoomLevel'] as num?)?.toDouble() ?? 10.0;
_currentRotation =
(settings['mapRotation'] as num?)?.toDouble() ?? 0.0;
final lat = (settings['mapCenterLat'] as num?)?.toDouble();
final lon = (settings['mapCenterLon'] as num?)?.toDouble();
if (lat != null && lon != null) {
_currentLocation = LatLng(lat, lon);
}
});
}
} catch (e) {}
}
Future<void> _saveSettings() async {
try {
final center = _mapController.camera.center;
await DatabaseService.instance.updateSettings({
'mapRailwayLayerVisible': _railwayLayerVisible ? 1 : 0,
'mapZoomLevel': _currentZoom,
'mapCenterLat': center.latitude,
'mapCenterLon': center.longitude,
'mapRotation': _currentRotation,
});
} catch (e) {}
}
Future<void> _loadTrainRecords() async {
setState(() => _isLoading = true);
try {
final records = await DatabaseService.instance.getAllRecords();
setState(() {
_trainRecords.clear();
_trainRecords.addAll(records);
_isLoading = false;
if (_trainRecords.isNotEmpty) {
final lastRecord = _trainRecords.first;
final coords = lastRecord.getCoordinates();
final dmsCoords = _parseDmsCoordinate(lastRecord.positionInfo);
if (dmsCoords != null) {
_lastTrainLocation = dmsCoords;
} else if (coords['lat'] != 0.0 && coords['lng'] != 0.0) {
_lastTrainLocation = LatLng(coords['lat']!, coords['lng']!);
}
}
_initializeMapPosition();
});
} catch (e) {
setState(() => _isLoading = false);
}
}
void _initializeMapPosition() {
if (_isMapInitialized) return;
LatLng? targetLocation;
if (_currentLocation != null) {
targetLocation = _currentLocation;
} else if (_userLocation != null) {
targetLocation = _userLocation;
} else if (_lastTrainLocation != null) {
targetLocation = _lastTrainLocation;
} else {
targetLocation = _defaultPosition;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
_centerMap(targetLocation!, zoom: _currentZoom);
_isMapInitialized = true;
});
}
void _centerMap(LatLng location, {double? zoom}) {
_mapController.move(location, zoom ?? _currentZoom);
}
LatLng? _parseDmsCoordinate(String? positionInfo) {
if (positionInfo == null ||
positionInfo.isEmpty ||
positionInfo == '<NUL>') {
return null;
}
try {
final parts = positionInfo.trim().split(' ');
if (parts.length >= 2) {
final latStr = parts[0];
final lngStr = parts[1];
final lat = _parseDmsString(latStr);
final lng = _parseDmsString(lngStr);
if (lat != null &&
lng != null &&
(lat.abs() > 0.001 || lng.abs() > 0.001)) {
return LatLng(lat, lng);
}
}
} catch (e) {
print('解析DMS坐标失败: $e');
}
return null;
}
double? _parseDmsString(String dmsStr) {
try {
final degreeIndex = dmsStr.indexOf('°');
if (degreeIndex == -1) return null;
final degrees = double.tryParse(dmsStr.substring(0, degreeIndex));
if (degrees == null) return null;
final minuteIndex = dmsStr.indexOf('');
if (minuteIndex == -1) return degrees;
final minutes =
double.tryParse(dmsStr.substring(degreeIndex + 1, minuteIndex));
if (minutes == null) return degrees;
return degrees + (minutes / 60.0);
} catch (e) {
return null;
}
}
List<TrainRecord> _getValidRecords() {
return _trainRecords.where((record) {
final coords = record.getCoordinates();
return coords['lat'] != 0.0 && coords['lng'] != 0.0;
}).toList();
}
List<TrainRecord> _getValidDmsRecords() {
return _trainRecords.where((record) {
return _parseDmsCoordinate(record.positionInfo) != null;
}).toList();
}
List<Marker> _buildTrainMarkers() {
final markers = <Marker>[];
final validRecords = [..._getValidRecords(), ..._getValidDmsRecords()];
for (final record in validRecords) {
LatLng? position;
final dmsPosition = _parseDmsCoordinate(record.positionInfo);
if (dmsPosition != null) {
position = dmsPosition;
} else {
final coords = record.getCoordinates();
if (coords['lat'] != 0.0 && coords['lng'] != 0.0) {
position = LatLng(coords['lat']!, coords['lng']!);
}
}
if (position != null) {
final trainDisplay =
record.fullTrainNumber.isEmpty ? "未知列车" : record.fullTrainNumber;
markers.add(
Marker(
point: position,
width: 80,
height: 60,
child: GestureDetector(
onTap: () => position != null
? _showTrainDetailsDialog(record, position)
: null,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: Colors.white, width: 2),
),
child: const Icon(
Icons.train,
color: Colors.white,
size: 18,
),
),
const SizedBox(height: 2),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(2),
),
child: Text(
trainDisplay,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
],
),
),
),
);
}
}
return markers;
}
void _centerToMyLocation() {
_centerMap(_lastTrainLocation ?? _defaultPosition, zoom: 15.0);
}
void _centerToLastTrain() {
if (_trainRecords.isNotEmpty) {
final lastRecord = _trainRecords.first;
final coords = lastRecord.getCoordinates();
final dmsCoords = _parseDmsCoordinate(lastRecord.positionInfo);
LatLng? targetPosition;
if (dmsCoords != null) {
targetPosition = dmsCoords;
} else if (coords['lat'] != 0.0 && coords['lng'] != 0.0) {
targetPosition = LatLng(coords['lat']!, coords['lng']!);
}
if (targetPosition != null) {
_centerMap(targetPosition, zoom: 15.0);
}
}
}
void _showTrainDetailsDialog(TrainRecord record, LatLng position) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) {
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 4,
height: 24,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
record.fullTrainNumber.isEmpty
? "未知列车"
: record.fullTrainNumber,
style:
Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 16),
Container(
width: double.infinity,
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceVariant
.withOpacity(0.3),
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
_buildMaterial3DetailRow(
context, "时间", record.formattedTime),
_buildMaterial3DetailRow(
context, "日期", record.formattedDate),
_buildMaterial3DetailRow(
context, "类型", record.trainType),
_buildMaterial3DetailRow(
context, "速度", "${record.speed} km/h"),
_buildMaterial3DetailRow(
context, "位置", record.position),
_buildMaterial3DetailRow(context, "路线", record.route),
_buildMaterial3DetailRow(
context, "机车", "${record.locoType}-${record.loco}"),
_buildMaterial3DetailRow(context, "坐标",
"${position.latitude.toStringAsFixed(4)}, ${position.longitude.toStringAsFixed(4)}"),
],
),
),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: FilledButton.tonal(
onPressed: () => Navigator.pop(context),
child: const Text('关闭'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: () {
Navigator.pop(context);
_centerMap(position, zoom: 17.0);
},
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.my_location, size: 16),
SizedBox(width: 8),
Text('居中查看'),
],
),
),
),
],
),
],
),
),
);
},
);
}
Widget _buildDetailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 80,
child: Text(
label,
style: const TextStyle(color: Colors.grey, fontSize: 14),
),
),
Expanded(
child: Text(
value.isEmpty ? "未知" : value,
style: const TextStyle(color: Colors.white, fontSize: 14),
),
),
],
),
);
}
Widget _buildMaterial3DetailRow(
BuildContext context, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 60,
child: Text(
label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
),
Expanded(
child: Text(
value.isEmpty ? "未知" : value,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final markers = _buildTrainMarkers();
if (_userLocation != null) {
markers.add(
Marker(
point: _userLocation!,
width: 40,
height: 40,
child: Container(
decoration: BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
child: const Icon(
Icons.my_location,
color: Colors.white,
size: 20,
),
),
),
);
}
return Scaffold(
backgroundColor: const Color(0xFF121212),
body: Stack(
children: [
FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCenter: _lastTrainLocation ?? _defaultPosition,
initialZoom: _currentZoom,
initialRotation: _currentRotation,
minZoom: 4.0,
maxZoom: 18.0,
onPositionChanged: (MapCamera camera, bool hasGesture) {
if (hasGesture) {
setState(() {
_currentLocation = camera.center;
_currentZoom = camera.zoom;
_currentRotation = camera.rotation;
});
_saveSettings();
}
},
onTap: (_, point) {
for (final record in _trainRecords) {
final coords = record.getCoordinates();
final dmsCoords = _parseDmsCoordinate(record.positionInfo);
LatLng? recordPosition;
if (dmsCoords != null) {
recordPosition = dmsCoords;
} else if (coords['lat'] != 0.0 && coords['lng'] != 0.0) {
recordPosition = LatLng(coords['lat']!, coords['lng']!);
}
if (recordPosition != null) {
final distance = const Distance()
.as(LengthUnit.Meter, recordPosition, point);
if (distance < 50) {
_showTrainDetailsDialog(record, recordPosition);
break;
}
}
}
},
),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'org.noxylva.lbjconsole',
),
if (_railwayLayerVisible)
TileLayer(
urlTemplate:
'https://{s}.tiles.openrailwaymap.org/standard/{z}/{x}/{y}.png',
subdomains: const ['a', 'b', 'c'],
userAgentPackageName: 'org.noxylva.lbjconsole',
),
MarkerLayer(
markers: markers,
),
],
),
if (_isLoading)
const Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF007ACC)),
),
),
Positioned(
right: 16,
top: 40,
child: Column(
children: [
FloatingActionButton.small(
heroTag: 'railwayLayer',
backgroundColor: const Color(0xFF1E1E1E),
onPressed: () {
setState(() {
_railwayLayerVisible = !_railwayLayerVisible;
});
_saveSettings();
},
child: Icon(
_railwayLayerVisible ? Icons.layers : Icons.layers_outlined,
color: Colors.white,
),
),
const SizedBox(height: 8),
FloatingActionButton.small(
heroTag: 'myLocation',
backgroundColor: const Color(0xFF1E1E1E),
onPressed: () {
_getCurrentLocation();
if (_userLocation != null) {
_centerMap(_userLocation!, zoom: 15.0);
}
},
child: const Icon(Icons.my_location, color: Colors.white),
),
const SizedBox(height: 8),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,805 @@
import 'package:flutter/material.dart';
import 'dart:async';
import 'dart:io';
import 'package:lbjconsole/models/merged_record.dart';
import 'package:lbjconsole/services/database_service.dart';
import 'package:lbjconsole/services/ble_service.dart';
import 'package:lbjconsole/themes/app_theme.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:file_picker/file_picker.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:package_info_plus/package_info_plus.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
late DatabaseService _databaseService;
late TextEditingController _deviceNameController;
String _deviceName = '';
bool _backgroundServiceEnabled = false;
bool _notificationsEnabled = true;
int _recordCount = 0;
bool _mergeRecordsEnabled = false;
GroupBy _groupBy = GroupBy.trainAndLoco;
TimeWindow _timeWindow = TimeWindow.unlimited;
@override
void initState() {
super.initState();
_databaseService = DatabaseService.instance;
_deviceNameController = TextEditingController();
_loadSettings();
_loadRecordCount();
}
@override
void dispose() {
_deviceNameController.dispose();
super.dispose();
}
Future<void> _loadSettings() async {
final settingsMap = await _databaseService.getAllSettings() ?? {};
final settings = MergeSettings.fromMap(settingsMap);
if (mounted) {
setState(() {
_deviceName = settingsMap['deviceName'] ?? 'LBJReceiver';
_deviceNameController.text = _deviceName;
_backgroundServiceEnabled =
(settingsMap['backgroundServiceEnabled'] ?? 0) == 1;
_notificationsEnabled = (settingsMap['notificationEnabled'] ?? 1) == 1;
_mergeRecordsEnabled = settings.enabled;
_groupBy = settings.groupBy;
_timeWindow = settings.timeWindow;
});
}
}
Future<void> _loadRecordCount() async {
final count = await _databaseService.getRecordCount();
if (mounted) {
setState(() {
_recordCount = count;
});
}
}
Future<void> _saveSettings() async {
await _databaseService.updateSettings({
'deviceName': _deviceName,
'backgroundServiceEnabled': _backgroundServiceEnabled ? 1 : 0,
'notificationEnabled': _notificationsEnabled ? 1 : 0,
'mergeRecordsEnabled': _mergeRecordsEnabled ? 1 : 0,
'groupBy': _groupBy.name,
'timeWindow': _timeWindow.name,
});
}
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildBluetoothSettings(),
const SizedBox(height: 20),
_buildAppSettings(),
const SizedBox(height: 20),
_buildMergeSettings(),
const SizedBox(height: 20),
_buildDataManagement(),
const SizedBox(height: 20),
_buildAboutSection(),
],
),
);
}
Widget _buildBluetoothSettings() {
return Card(
color: AppTheme.tertiaryBlack,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.0),
),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.bluetooth,
color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 12),
Text('蓝牙设备', style: AppTheme.titleMedium),
],
),
const SizedBox(height: 16),
TextField(
controller: _deviceNameController,
decoration: InputDecoration(
labelText: '设备名称 (用于自动连接)',
hintText: '输入设备名称',
labelStyle: const TextStyle(color: Colors.white70),
hintStyle: const TextStyle(color: Colors.white54),
border: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.white54),
borderRadius: BorderRadius.circular(12.0),
),
enabledBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.white54),
borderRadius: BorderRadius.circular(12.0),
),
focusedBorder: OutlineInputBorder(
borderSide:
BorderSide(color: Theme.of(context).colorScheme.primary),
borderRadius: BorderRadius.circular(12.0),
),
),
style: const TextStyle(color: Colors.white),
onChanged: (value) {
setState(() {
_deviceName = value;
});
_saveSettings();
},
),
],
),
),
);
}
Widget _buildAppSettings() {
return Card(
color: AppTheme.tertiaryBlack,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.0),
),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.settings,
color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 12),
Text('应用设置', style: AppTheme.titleMedium),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('后台保活服务', style: AppTheme.bodyLarge),
Text('保持应用在后台运行', style: AppTheme.caption),
],
),
Switch(
value: _backgroundServiceEnabled,
onChanged: (value) {
setState(() {
_backgroundServiceEnabled = value;
});
_saveSettings();
},
activeColor: Theme.of(context).colorScheme.primary,
),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('LBJ消息通知', style: AppTheme.bodyLarge),
Text('接收LBJ消息通知', style: AppTheme.caption),
],
),
Switch(
value: _notificationsEnabled,
onChanged: (value) {
setState(() {
_notificationsEnabled = value;
});
_saveSettings();
},
activeColor: Theme.of(context).colorScheme.primary,
),
],
),
],
),
),
);
}
Widget _buildMergeSettings() {
return Card(
color: AppTheme.tertiaryBlack,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.0),
),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.merge_type,
color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 12),
Text('记录合并', style: AppTheme.titleMedium),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('启用记录合并', style: AppTheme.bodyLarge),
Text('合并相同内容的LBJ记录', style: AppTheme.caption),
],
),
Switch(
value: _mergeRecordsEnabled,
onChanged: (value) {
setState(() {
_mergeRecordsEnabled = value;
});
_saveSettings();
},
activeColor: Theme.of(context).colorScheme.primary,
),
],
),
Visibility(
visible: _mergeRecordsEnabled,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
Text('分组方式', style: AppTheme.bodyLarge),
const SizedBox(height: 8),
DropdownButtonFormField<GroupBy>(
value: _groupBy,
items: [
DropdownMenuItem(
value: GroupBy.trainOnly,
child: Text('仅车次号', style: AppTheme.bodyMedium)),
DropdownMenuItem(
value: GroupBy.locoOnly,
child: Text('仅机车号', style: AppTheme.bodyMedium)),
DropdownMenuItem(
value: GroupBy.trainOrLoco,
child: Text('车次号或机车号', style: AppTheme.bodyMedium)),
DropdownMenuItem(
value: GroupBy.trainAndLoco,
child: Text('车次号与机车号', style: AppTheme.bodyMedium)),
],
onChanged: (value) {
if (value != null) {
setState(() {
_groupBy = value;
});
_saveSettings();
}
},
decoration: InputDecoration(
filled: true,
fillColor: AppTheme.secondaryBlack,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12.0),
borderSide: BorderSide.none,
),
),
dropdownColor: AppTheme.secondaryBlack,
style: AppTheme.bodyMedium,
),
const SizedBox(height: 16),
Text('时间窗口', style: AppTheme.bodyLarge),
const SizedBox(height: 8),
DropdownButtonFormField<TimeWindow>(
value: _timeWindow,
items: [
DropdownMenuItem(
value: TimeWindow.oneHour,
child: Text('1小时内', style: AppTheme.bodyMedium)),
DropdownMenuItem(
value: TimeWindow.twoHours,
child: Text('2小时内', style: AppTheme.bodyMedium)),
DropdownMenuItem(
value: TimeWindow.sixHours,
child: Text('6小时内', style: AppTheme.bodyMedium)),
DropdownMenuItem(
value: TimeWindow.twelveHours,
child: Text('12小时内', style: AppTheme.bodyMedium)),
DropdownMenuItem(
value: TimeWindow.oneDay,
child: Text('24小时内', style: AppTheme.bodyMedium)),
DropdownMenuItem(
value: TimeWindow.unlimited,
child: Text('不限时间', style: AppTheme.bodyMedium)),
],
onChanged: (value) {
if (value != null) {
setState(() {
_timeWindow = value;
});
_saveSettings();
}
},
decoration: InputDecoration(
filled: true,
fillColor: AppTheme.secondaryBlack,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12.0),
borderSide: BorderSide.none,
),
),
dropdownColor: AppTheme.secondaryBlack,
style: AppTheme.bodyMedium,
),
],
),
),
],
),
),
);
}
Widget _buildDataManagement() {
return Card(
color: AppTheme.tertiaryBlack,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.0),
),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.share, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 12),
Text('数据导出', style: AppTheme.titleMedium),
],
),
const SizedBox(height: 16),
_buildActionButton(
icon: Icons.download,
title: '导出数据',
subtitle: '将记录导出为JSON文件',
onTap: _exportData,
),
const SizedBox(height: 12),
_buildActionButton(
icon: Icons.file_download,
title: '导入数据',
subtitle: '从JSON文件导入记录和设置',
onTap: _importData,
),
const SizedBox(height: 12),
_buildActionButton(
icon: Icons.clear_all,
title: '清空数据',
subtitle: '删除所有记录和设置',
onTap: _clearAllData,
isDestructive: true,
),
],
),
),
);
}
Widget _buildActionButton({
required IconData icon,
required String title,
required String subtitle,
required VoidCallback onTap,
bool isDestructive = false,
}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12.0),
child: Container(
padding: const EdgeInsets.all(12.0),
decoration: BoxDecoration(
color: isDestructive
? Colors.red.withOpacity(0.1)
: AppTheme.secondaryBlack,
borderRadius: BorderRadius.circular(12.0),
border: Border.all(
color: isDestructive
? Colors.red.withOpacity(0.3)
: Colors.transparent,
width: 1,
),
),
child: Row(
children: [
Icon(
icon,
color: isDestructive
? Colors.red
: Theme.of(context).colorScheme.primary,
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: AppTheme.bodyLarge.copyWith(
color: isDestructive ? Colors.red : Colors.white,
),
),
Text(
subtitle,
style: AppTheme.caption,
),
],
),
),
Icon(
Icons.chevron_right,
color: Colors.white54,
size: 20,
),
],
),
),
);
}
String _formatFileSize(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB';
}
String _formatDateTime(DateTime dateTime) {
return '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')} '
'${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
}
Future<String> _getAppVersion() async {
try {
final packageInfo = await PackageInfo.fromPlatform();
return 'v${packageInfo.version}';
} catch (e) {
return '';
}
}
Future<String?> _selectDirectory() async {
try {
// 使用文件选择器选择目录
final directory = await FilePicker.platform.getDirectoryPath(
dialogTitle: '选择导出位置',
lockParentWindow: true,
);
return directory;
} catch (e) {
// 如果文件选择器失败,使用默认的文档目录
try {
final documentsDir = await getApplicationDocumentsDirectory();
final exportDir = Directory(path.join(documentsDir.path, 'LBJ_Exports'));
if (!await exportDir.exists()) {
await exportDir.create(recursive: true);
}
return exportDir.path;
} catch (e) {
return null;
}
}
}
Future<void> _exportData() async {
final scaffoldMessenger = ScaffoldMessenger.of(context);
try {
// 让用户选择保存位置
final fileName =
'LBJ_Console_${DateTime.now().year}${DateTime.now().month.toString().padLeft(2, '0')}${DateTime.now().day.toString().padLeft(2, '0')}.json';
String? selectedDirectory = await _selectDirectory();
if (selectedDirectory == null) return;
final filePath = path.join(selectedDirectory, fileName);
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const AlertDialog(
content: Row(
children: [
CircularProgressIndicator(),
SizedBox(width: 16),
Text('正在导出数据...'),
],
),
),
);
try {
final exportedPath = await _databaseService.exportDataAsJson(customPath: filePath);
Navigator.pop(context);
if (exportedPath != null) {
final file = File(exportedPath);
final fileName = file.path.split(Platform.pathSeparator).last;
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text('数据已导出到:$fileName'),
action: SnackBarAction(
label: '查看',
onPressed: () async {
// 打开文件所在目录
try {
final directory = file.parent;
await Process.run('explorer', [directory.path]);
} catch (e) {
// 如果无法打开目录,显示路径
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text('文件路径:${file.path}'),
),
);
}
},
),
),
);
} else {
scaffoldMessenger.showSnackBar(
const SnackBar(
content: Text('导出失败'),
),
);
}
} catch (e) {
Navigator.pop(context);
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text('导出错误:$e'),
),
);
}
} catch (e) {
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text('选择目录错误:$e'),
),
);
}
}
Future<void> _importData() async {
final scaffoldMessenger = ScaffoldMessenger.of(context);
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('导入数据'),
content: const Text('导入将替换所有现有数据,是否继续?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('继续'),
),
],
),
);
if (result != true) return;
final resultFile = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['json'],
);
if (resultFile == null) return;
final selectedFile = resultFile.files.single.path;
if (selectedFile == null) return;
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const AlertDialog(
content: Row(
children: [
CircularProgressIndicator(),
SizedBox(width: 16),
Text('正在导入数据...'),
],
),
),
);
try {
final success = await _databaseService.importDataFromJson(selectedFile);
Navigator.pop(context);
if (success) {
scaffoldMessenger.showSnackBar(
const SnackBar(
content: Text('数据导入成功'),
),
);
await _loadSettings();
await _loadRecordCount();
setState(() {});
} else {
scaffoldMessenger.showSnackBar(
const SnackBar(
content: Text('数据导入失败'),
),
);
}
} catch (e) {
Navigator.pop(context);
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text('导入错误:$e'),
),
);
}
}
Future<void> _clearAllData() async {
final scaffoldMessenger = ScaffoldMessenger.of(context);
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('清空数据'),
content: const Text('此操作将删除所有记录和设置,无法撤销。是否继续?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('确认清空'),
),
],
),
);
if (result != true) return;
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const AlertDialog(
content: Row(
children: [
CircularProgressIndicator(),
SizedBox(width: 16),
Text('正在清空数据...'),
],
),
),
);
try {
await _databaseService.deleteAllRecords();
await _databaseService.updateSettings({
'deviceName': 'LBJReceiver',
'backgroundServiceEnabled': 0,
'notificationEnabled': 1,
'mergeRecordsEnabled': 0,
'groupBy': 'trainAndLoco',
'timeWindow': 'unlimited',
});
Navigator.pop(context);
scaffoldMessenger.showSnackBar(
const SnackBar(
content: Text('数据已清空'),
),
);
await _loadSettings();
await _loadRecordCount();
setState(() {});
} catch (e) {
Navigator.pop(context);
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text('清空错误:$e'),
),
);
}
}
Widget _buildAboutSection() {
return Card(
color: AppTheme.tertiaryBlack,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.0),
),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.info, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 12),
Text('关于', style: AppTheme.titleMedium),
],
),
const SizedBox(height: 16),
Text('LBJ Console', style: AppTheme.titleMedium),
const SizedBox(height: 8),
FutureBuilder<String>(
future: _getAppVersion(),
builder: (context, snapshot) {
if (snapshot.hasData) {
return Text(snapshot.data!, style: AppTheme.bodyMedium);
} else {
return const Text('v0.1.3-flutter', style: AppTheme.bodyMedium);
}
},
),
const SizedBox(height: 16),
GestureDetector(
onTap: () async {
final url = Uri.parse('https://github.com/undef-i/LBJConsole');
if (await canLaunchUrl(url)) {
await launchUrl(url);
}
},
child: Text(
'https://github.com/undef-i/LBJConsole',
style: AppTheme.caption,
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,361 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:lbjconsole/models/train_record.dart';
import 'package:lbjconsole/services/database_service.dart';
class BLEService {
static final BLEService _instance = BLEService._internal();
factory BLEService() => _instance;
BLEService._internal();
static const String TAG = "LBJ_BT_FLUTTER";
static final Guid serviceUuid = Guid("0000ffe0-0000-1000-8000-00805f9b34fb");
static final Guid charUuid = Guid("0000ffe1-0000-1000-8000-00805f9b34fb");
BluetoothDevice? _connectedDevice;
BluetoothCharacteristic? _characteristic;
StreamSubscription<List<int>>? _valueSubscription;
StreamSubscription<BluetoothConnectionState>? _connectionStateSubscription;
StreamSubscription<List<ScanResult>>? _scanResultsSubscription;
final StreamController<String> _statusController =
StreamController<String>.broadcast();
final StreamController<TrainRecord> _dataController =
StreamController<TrainRecord>.broadcast();
final StreamController<bool> _connectionController =
StreamController<bool>.broadcast();
Stream<String> get statusStream => _statusController.stream;
Stream<TrainRecord> get dataStream => _dataController.stream;
Stream<bool> get connectionStream => _connectionController.stream;
String _deviceStatus = "未连接";
String? _lastKnownDeviceAddress;
String _targetDeviceName = "LBJReceiver";
bool _isConnecting = false;
bool _isManualDisconnect = false;
bool _isAutoConnectBlocked = false;
Timer? _heartbeatTimer;
final StringBuffer _dataBuffer = StringBuffer();
void initialize() {
_loadSettings();
FlutterBluePlus.adapterState.listen((state) {
if (state == BluetoothAdapterState.on) {
ensureConnection();
} else {
_updateConnectionState(false, "蓝牙已关闭");
stopScan();
}
});
_startHeartbeat();
}
void _startHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = Timer.periodic(const Duration(seconds: 7), (timer) {
ensureConnection();
});
}
Future<void> _loadSettings() async {
try {
final settings = await DatabaseService.instance.getAllSettings();
if (settings != null) {
_targetDeviceName = settings['deviceName'] ?? 'LBJReceiver';
}
} catch (e) {
}
}
void ensureConnection() {
if (isConnected || _isConnecting) {
return;
}
_tryReconnectDirectly();
}
Future<void> _tryReconnectDirectly() async {
if (_lastKnownDeviceAddress == null) {
startScan();
return;
}
_isConnecting = true;
_statusController.add("正在重连...");
try {
final connected = await FlutterBluePlus.connectedSystemDevices;
final matchingDevices =
connected.where((d) => d.remoteId.str == _lastKnownDeviceAddress);
BluetoothDevice? target =
matchingDevices.isNotEmpty ? matchingDevices.first : null;
if (target != null) {
await connect(target);
} else {
startScan();
_isConnecting = false;
}
} catch (e) {
startScan();
_isConnecting = false;
}
}
Future<void> startScan({
String? targetName,
Duration? timeout,
Function(List<BluetoothDevice>)? onScanResults,
}) async {
if (FlutterBluePlus.isScanningNow) {
return;
}
_targetDeviceName = targetName ?? _targetDeviceName;
_statusController.add("正在扫描...");
_scanResultsSubscription?.cancel();
_scanResultsSubscription = FlutterBluePlus.scanResults.listen((results) {
final allFoundDevices = results.map((r) => r.device).toList();
final filteredDevices = allFoundDevices.where((device) {
if (_targetDeviceName.isEmpty) return true;
return device.platformName.toLowerCase() ==
_targetDeviceName.toLowerCase();
}).toList();
onScanResults?.call(filteredDevices);
if (isConnected ||
_isConnecting ||
_isManualDisconnect ||
_isAutoConnectBlocked) return;
for (var device in allFoundDevices) {
if (_shouldAutoConnectTo(device)) {
stopScan();
connect(device);
break;
}
}
});
try {
await FlutterBluePlus.startScan(timeout: timeout);
} catch (e) {
_statusController.add("扫描失败");
}
}
bool _shouldAutoConnectTo(BluetoothDevice device) {
final deviceName = device.platformName;
final deviceAddress = device.remoteId.str;
if (_targetDeviceName.isNotEmpty &&
deviceName.toLowerCase() == _targetDeviceName.toLowerCase())
return true;
if (_lastKnownDeviceAddress != null &&
_lastKnownDeviceAddress == deviceAddress) return true;
return false;
}
Future<void> stopScan() async {
await FlutterBluePlus.stopScan();
_scanResultsSubscription?.cancel();
}
Future<void> connect(BluetoothDevice device) async {
if (isConnected) return;
_isConnecting = true;
_isManualDisconnect = false;
_statusController.add("正在连接: ${device.platformName}");
try {
_connectionStateSubscription?.cancel();
_connectionStateSubscription = device.connectionState.listen((state) {
if (state == BluetoothConnectionState.disconnected) {
_onDisconnected();
}
});
await device.connect(timeout: const Duration(seconds: 15));
await _onConnected(device);
} catch (e) {
_onDisconnected();
}
}
Future<void> _onConnected(BluetoothDevice device) async {
_connectedDevice = device;
_lastKnownDeviceAddress = device.remoteId.str;
await _discoverServicesAndSetupNotifications(device);
}
void _onDisconnected() {
final wasConnected = isConnected;
_updateConnectionState(false, "连接已断开");
_connectionStateSubscription?.cancel();
if (wasConnected && !_isManualDisconnect) {
ensureConnection();
}
_isConnecting = false;
}
Future<void> _discoverServicesAndSetupNotifications(
BluetoothDevice device) async {
try {
final services = await device.discoverServices();
for (var service in services) {
if (service.uuid == serviceUuid) {
for (var char in service.characteristics) {
if (char.uuid == charUuid) {
_characteristic = char;
await device.requestMtu(512);
await char.setNotifyValue(true);
_valueSubscription = char.lastValueStream.listen(_onDataReceived);
_updateConnectionState(true, "已连接");
_isConnecting = false;
return;
}
}
}
}
await device.disconnect();
} catch (e) {
await device.disconnect();
}
}
Future<void> connectManually(BluetoothDevice device) async {
_isManualDisconnect = false;
_isAutoConnectBlocked = false;
stopScan();
await connect(device);
}
Future<void> disconnect() async {
_isManualDisconnect = true;
stopScan();
await _connectionStateSubscription?.cancel();
await _valueSubscription?.cancel();
if (_connectedDevice != null) {
await _connectedDevice!.disconnect();
}
_onDisconnected();
}
void _onDataReceived(List<int> value) {
if (value.isEmpty) return;
try {
final data = utf8.decode(value);
_dataBuffer.write(data);
_processDataBuffer();
} catch (e) {}
}
void _processDataBuffer() {
String bufferContent = _dataBuffer.toString();
if (bufferContent.isEmpty) return;
int firstBrace = bufferContent.indexOf('{');
if (firstBrace == -1) {
_dataBuffer.clear();
return;
}
bufferContent = bufferContent.substring(firstBrace);
int braceCount = 0;
int lastValidJsonEnd = -1;
for (int i = 0; i < bufferContent.length; i++) {
if (bufferContent[i] == '{') {
braceCount++;
} else if (bufferContent[i] == '}') {
braceCount--;
}
if (braceCount == 0 && i > 0) {
lastValidJsonEnd = i;
String jsonToParse = bufferContent.substring(0, lastValidJsonEnd + 1);
_parseAndNotify(jsonToParse);
bufferContent = bufferContent.substring(lastValidJsonEnd + 1);
i = -1;
firstBrace = bufferContent.indexOf('{');
if (firstBrace != -1) {
bufferContent = bufferContent.substring(firstBrace);
} else {
break;
}
}
}
_dataBuffer.clear();
if (braceCount > 0) {
_dataBuffer.write(bufferContent);
}
}
void _parseAndNotify(String jsonData) {
try {
final decodedJson = jsonDecode(jsonData);
if (decodedJson is Map<String, dynamic>) {
final now = DateTime.now();
final recordData = Map<String, dynamic>.from(decodedJson);
recordData['uniqueId'] =
'${now.millisecondsSinceEpoch}_${Random().nextInt(9999)}';
recordData['receivedTimestamp'] = now.millisecondsSinceEpoch;
final trainRecord = TrainRecord.fromJson(recordData);
_dataController.add(trainRecord);
DatabaseService.instance.insertRecord(trainRecord);
}
} catch (e) {
print("$TAG: JSON Decode Error: $e, Data: $jsonData");
}
}
void _updateConnectionState(bool connected, String status) {
if (connected) {
_deviceStatus = "已连接";
} else {
_deviceStatus = status;
_connectedDevice = null;
_characteristic = null;
}
_statusController.add(_deviceStatus);
_connectionController.add(connected);
}
void onAppResume() {
ensureConnection();
}
void setAutoConnectBlocked(bool blocked) {
_isAutoConnectBlocked = blocked;
}
bool get isConnected => _connectedDevice != null;
String get deviceStatus => _deviceStatus;
String? get deviceAddress => _connectedDevice?.remoteId.str;
bool get isScanning => FlutterBluePlus.isScanningNow;
BluetoothDevice? get connectedDevice => _connectedDevice;
bool get isManualDisconnect => _isManualDisconnect;
void dispose() {
_heartbeatTimer?.cancel();
disconnect();
_statusController.close();
_dataController.close();
_connectionController.close();
}
}

View File

@@ -0,0 +1,320 @@
import 'dart:async';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:io';
import 'dart:convert';
import 'package:lbjconsole/models/train_record.dart';
class DatabaseService {
static final DatabaseService instance = DatabaseService._internal();
factory DatabaseService() => instance;
DatabaseService._internal();
static const String _databaseName = 'train_database';
static const _databaseVersion = 1;
static const String trainRecordsTable = 'train_records';
static const String appSettingsTable = 'app_settings';
Database? _database;
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDatabase();
return _database!;
}
Future<Database> _initDatabase() async {
final directory = await getApplicationDocumentsDirectory();
final path = join(directory.path, _databaseName);
return await openDatabase(
path,
version: _databaseVersion,
onCreate: _onCreate,
);
}
Future<void> _onCreate(Database db, int version) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS $trainRecordsTable (
uniqueId TEXT PRIMARY KEY,
timestamp INTEGER NOT NULL,
receivedTimestamp INTEGER NOT NULL,
train TEXT NOT NULL,
direction INTEGER NOT NULL,
speed TEXT NOT NULL,
position TEXT NOT NULL,
time TEXT NOT NULL,
loco TEXT NOT NULL,
locoType TEXT NOT NULL,
lbjClass TEXT NOT NULL,
route TEXT NOT NULL,
positionInfo TEXT NOT NULL,
rssi REAL NOT NULL
)
''');
await db.execute('''
CREATE TABLE IF NOT EXISTS $appSettingsTable (
id INTEGER PRIMARY KEY,
deviceName TEXT NOT NULL DEFAULT 'LBJReceiver',
currentTab INTEGER NOT NULL DEFAULT 0,
historyEditMode INTEGER NOT NULL DEFAULT 0,
historySelectedRecords TEXT NOT NULL DEFAULT '',
historyExpandedStates TEXT NOT NULL DEFAULT '',
historyScrollPosition INTEGER NOT NULL DEFAULT 0,
historyScrollOffset INTEGER NOT NULL DEFAULT 0,
settingsScrollPosition INTEGER NOT NULL DEFAULT 0,
mapCenterLat REAL,
mapCenterLon REAL,
mapZoomLevel REAL NOT NULL DEFAULT 10.0,
mapRailwayLayerVisible INTEGER NOT NULL DEFAULT 1,
mapRotation REAL NOT NULL DEFAULT 0.0,
specifiedDeviceAddress TEXT,
searchOrderList TEXT NOT NULL DEFAULT '',
autoConnectEnabled INTEGER NOT NULL DEFAULT 1,
backgroundServiceEnabled INTEGER NOT NULL DEFAULT 0,
notificationEnabled INTEGER NOT NULL DEFAULT 0,
mergeRecordsEnabled INTEGER NOT NULL DEFAULT 0,
groupBy TEXT NOT NULL DEFAULT 'trainAndLoco',
timeWindow TEXT NOT NULL DEFAULT 'unlimited'
)
''');
await db.insert(appSettingsTable, {
'id': 1,
'deviceName': 'LBJReceiver',
'currentTab': 0,
'historyEditMode': 0,
'historySelectedRecords': '',
'historyExpandedStates': '',
'historyScrollPosition': 0,
'historyScrollOffset': 0,
'settingsScrollPosition': 0,
'mapZoomLevel': 10.0,
'mapRailwayLayerVisible': 1,
'mapRotation': 0.0,
'searchOrderList': '',
'autoConnectEnabled': 1,
'backgroundServiceEnabled': 0,
'notificationEnabled': 0,
'mergeRecordsEnabled': 0,
'groupBy': 'trainAndLoco',
'timeWindow': 'unlimited',
});
}
Future<int> insertRecord(TrainRecord record) async {
final db = await database;
return await db.insert(
trainRecordsTable,
record.toDatabaseJson(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<List<TrainRecord>> getAllRecords() async {
final db = await database;
final result = await db.query(
trainRecordsTable,
orderBy: 'timestamp DESC',
);
return result.map((json) => TrainRecord.fromDatabaseJson(json)).toList();
}
Future<int> deleteRecord(String uniqueId) async {
final db = await database;
return await db.delete(
trainRecordsTable,
where: 'uniqueId = ?',
whereArgs: [uniqueId],
);
}
Future<int> deleteAllRecords() async {
final db = await database;
return await db.delete(trainRecordsTable);
}
Future<int> getRecordCount() async {
final db = await database;
final result = await db.rawQuery('SELECT COUNT(*) FROM $trainRecordsTable');
return Sqflite.firstIntValue(result) ?? 0;
}
Future<TrainRecord?> getLatestRecord() async {
final db = await database;
final result = await db.query(
trainRecordsTable,
orderBy: 'timestamp DESC',
limit: 1,
);
if (result.isNotEmpty) {
return TrainRecord.fromDatabaseJson(result.first);
}
return null;
}
Future<Map<String, dynamic>?> getAllSettings() async {
final db = await database;
try {
final result = await db.query(
appSettingsTable,
where: 'id = 1',
);
if (result.isEmpty) return null;
return result.first;
} catch (e) {
return null;
}
}
Future<int> updateSettings(Map<String, dynamic> settings) async {
final db = await database;
return await db.update(
appSettingsTable,
settings,
where: 'id = 1',
);
}
Future<int> setSetting(String key, dynamic value) async {
final db = await database;
return await db.update(
appSettingsTable,
{key: value},
where: 'id = 1',
);
}
Future<List<String>> getSearchOrderList() async {
final settings = await getAllSettings();
if (settings != null && settings['searchOrderList'] != null) {
final listString = settings['searchOrderList'] as String;
if (listString.isNotEmpty) {
return listString.split(',');
}
}
return [];
}
Future<int> updateSearchOrderList(List<String> orderList) async {
return await setSetting('searchOrderList', orderList.join(','));
}
Future<Map<String, dynamic>> getDatabaseInfo() async {
final db = await database;
final count = await getRecordCount();
final settings = await getAllSettings();
return {
'databaseVersion': _databaseVersion,
'trainRecordCount': count,
'appSettings': settings,
'path': db.path,
};
}
Future<String?> backupDatabase() async {
try {
final db = await database;
final directory = await getApplicationDocumentsDirectory();
final originalPath = db.path;
final backupDirectory = Directory(join(directory.path, 'backups'));
if (!await backupDirectory.exists()) {
await backupDirectory.create(recursive: true);
}
final backupPath = join(backupDirectory.path,
'train_database_backup_${DateTime.now().millisecondsSinceEpoch}.db');
await File(originalPath).copy(backupPath);
return backupPath;
} catch (e) {
return null;
}
}
Future<void> deleteRecords(List<String> uniqueIds) async {
final db = await database;
for (String id in uniqueIds) {
await db.delete(
'train_records',
where: 'uniqueId = ?',
whereArgs: [id],
);
}
}
Future<void> close() async {
if (_database != null) {
await _database!.close();
_database = null;
}
}
Future<String?> exportDataAsJson({String? customPath}) async {
try {
final records = await getAllRecords();
final exportData = {
'records': records.map((r) => r.toDatabaseJson()).toList(),
};
final jsonString = jsonEncode(exportData);
String filePath;
if (customPath != null) {
filePath = customPath;
} else {
final tempDir = Directory.systemTemp;
final fileName =
'LBJ_Console_${DateTime.now().year}${DateTime.now().month.toString().padLeft(2, '0')}${DateTime.now().day.toString().padLeft(2, '0')}.json';
filePath = join(tempDir.path, fileName);
}
await File(filePath).writeAsString(jsonString);
return filePath;
} catch (e) {
return null;
}
}
Future<bool> importDataFromJson(String filePath) async {
try {
final jsonString = await File(filePath).readAsString();
final importData = jsonDecode(jsonString);
final db = await database;
await db.transaction((txn) async {
await txn.delete(trainRecordsTable);
if (importData['records'] != null) {
final records =
List<Map<String, dynamic>>.from(importData['records']);
for (final record in records) {
await txn.insert(trainRecordsTable, record);
}
}
});
return true;
} catch (e) {
return false;
}
}
Future<bool> deleteExportFile(String filePath) async {
try {
final file = File(filePath);
if (await file.exists()) {
await file.delete();
return true;
}
return false;
} catch (e) {
return false;
}
}
}

View File

@@ -0,0 +1,17 @@
import 'package:lbjconsole/util/loco_type_util.dart';
class LocoTypeService {
static final LocoTypeService _instance = LocoTypeService._internal();
factory LocoTypeService() => _instance;
LocoTypeService._internal();
bool _isInitialized = false;
Future<void> initialize() async {
if (_isInitialized) return;
_isInitialized = true;
}
bool get isInitialized => _isInitialized;
}

View File

@@ -0,0 +1,85 @@
import 'package:lbjconsole/models/train_record.dart';
import 'package:lbjconsole/models/merged_record.dart';
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 hasLoco = loco.isNotEmpty && loco != "<NUL>";
switch (groupBy) {
case GroupBy.trainOnly:
return hasTrain ? train : null;
case GroupBy.locoOnly:
return hasLoco ? loco : null;
case GroupBy.trainOrLoco:
if (hasTrain) return train;
if (hasLoco) return loco;
return null;
case GroupBy.trainAndLoco:
return (hasTrain && hasLoco) ? "${train}_$loco" : null;
}
}
static List<Object> getMixedList(
List<TrainRecord> allRecords, MergeSettings settings) {
if (!settings.enabled) {
allRecords
.sort((a, b) => b.receivedTimestamp.compareTo(a.receivedTimestamp));
return allRecords;
}
final now = DateTime.now();
final validRecords = settings.timeWindow.duration == null
? allRecords
: allRecords
.where((r) =>
now.difference(r.receivedTimestamp) <=
settings.timeWindow.duration!)
.toList();
final groupedRecords = <String, List<TrainRecord>>{};
for (final record in validRecords) {
final key = _generateGroupKey(record, settings.groupBy);
if (key != null) {
groupedRecords.putIfAbsent(key, () => []).add(record);
}
}
final List<MergedTrainRecord> mergedRecords = [];
final Set<String> mergedRecordIds = {};
groupedRecords.forEach((key, group) {
if (group.length >= 2) {
group
.sort((a, b) => b.receivedTimestamp.compareTo(a.receivedTimestamp));
final latestRecord = group.first;
mergedRecords.add(MergedTrainRecord(
groupKey: key,
records: group,
latestRecord: latestRecord,
));
for (final record in group) {
mergedRecordIds.add(record.uniqueId);
}
}
});
final singleRecords =
allRecords.where((r) => !mergedRecordIds.contains(r.uniqueId)).toList();
final List<Object> mixedList = [...mergedRecords, ...singleRecords];
mixedList.sort((a, b) {
final aTime = a is MergedTrainRecord
? a.latestRecord.receivedTimestamp
: (a as TrainRecord).receivedTimestamp;
final bTime = b is MergedTrainRecord
? b.latestRecord.receivedTimestamp
: (b as TrainRecord).receivedTimestamp;
return bTime.compareTo(aTime);
});
return mixedList;
}
}

View File

@@ -0,0 +1,135 @@
import 'dart:async';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:lbjconsole/models/train_record.dart';
class NotificationService {
static const String channelId = 'lbj_messages';
static const String channelName = 'LBJ Messages';
static const String channelDescription = 'Receive LBJ messages';
final FlutterLocalNotificationsPlugin _notificationsPlugin =
FlutterLocalNotificationsPlugin();
int _notificationId = 1000;
bool _notificationsEnabled = true;
final StreamController<bool> _settingsController =
StreamController<bool>.broadcast();
Stream<bool> get settingsStream => _settingsController.stream;
Future<void> initialize() async {
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
final InitializationSettings initializationSettings =
InitializationSettings(
android: initializationSettingsAndroid,
);
await _notificationsPlugin.initialize(
initializationSettings,
onDidReceiveNotificationResponse: (details) {},
);
await _createNotificationChannel();
_notificationsEnabled = await isNotificationEnabled();
_settingsController.add(_notificationsEnabled);
}
Future<void> _createNotificationChannel() async {
const AndroidNotificationChannel channel = AndroidNotificationChannel(
channelId,
channelName,
description: channelDescription,
importance: Importance.high,
enableVibration: true,
playSound: true,
);
await _notificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(channel);
}
Future<void> showTrainNotification(TrainRecord record) async {
if (!_notificationsEnabled) return;
if (!_isValidValue(record.train) ||
!_isValidValue(record.route) ||
!_isValidValue(record.directionText)) {
return;
}
final String title = '列车信息更新';
final String body = _buildNotificationContent(record);
final AndroidNotificationDetails androidPlatformChannelSpecifics =
AndroidNotificationDetails(
channelId,
channelName,
channelDescription: channelDescription,
importance: Importance.high,
priority: Priority.high,
ticker: 'ticker',
styleInformation: BigTextStyleInformation(body),
);
final NotificationDetails platformChannelSpecifics =
NotificationDetails(android: androidPlatformChannelSpecifics);
await _notificationsPlugin.show(
_notificationId++,
title,
body,
platformChannelSpecifics,
payload: 'train_${record.train}',
);
}
String _buildNotificationContent(TrainRecord record) {
final buffer = StringBuffer();
buffer.writeln('车次: ${record.fullTrainNumber}');
buffer.writeln('线路: ${record.route}');
buffer.writeln('方向: ${record.directionText}');
if (_isValidValue(record.speed)) {
buffer.writeln('速度: ${record.speed} km/h');
}
if (_isValidValue(record.positionInfo)) {
buffer.writeln('位置: ${record.positionInfo}');
}
buffer.writeln('时间: ${record.formattedTime}');
return buffer.toString().trim();
}
bool _isValidValue(String? value) {
if (value == null || value.isEmpty) return false;
final trimmed = value.trim();
return trimmed.isNotEmpty &&
trimmed != 'NUL' &&
trimmed != 'NA' &&
trimmed != '*';
}
Future<void> enableNotifications(bool enable) async {
_notificationsEnabled = enable;
_settingsController.add(_notificationsEnabled);
}
Future<bool> isNotificationEnabled() async {
return _notificationsEnabled;
}
Future<void> cancelAllNotifications() async {
await _notificationsPlugin.cancelAll();
}
void dispose() {
_settingsController.close();
}
}

175
lib/themes/app_theme.dart Normal file
View File

@@ -0,0 +1,175 @@
import 'package:flutter/material.dart';
class AppTheme {
static ThemeData get darkTheme {
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: Colors.black,
canvasColor: Colors.black,
cardColor: const Color(0xFF121212),
primaryColor: Colors.blue,
colorScheme: ColorScheme.dark(
primary: Colors.blue,
secondary: Colors.blueAccent,
surface: const Color(0xFF121212),
background: Colors.black,
onSurface: Colors.white,
onBackground: Colors.white,
),
appBarTheme: const AppBarTheme(
backgroundColor: Colors.black,
elevation: 0,
titleTextStyle: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
iconTheme: IconThemeData(color: Colors.white),
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: Color(0xFF121212),
selectedItemColor: Colors.blue,
unselectedItemColor: Colors.grey,
type: BottomNavigationBarType.fixed,
),
cardTheme: CardTheme(
color: const Color(0xFF1E1E1E),
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
textTheme: const TextTheme(
headlineLarge: TextStyle(
color: Colors.white, fontSize: 32, fontWeight: FontWeight.bold),
headlineMedium: TextStyle(
color: Colors.white, fontSize: 28, fontWeight: FontWeight.bold),
headlineSmall: TextStyle(
color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold),
titleLarge: TextStyle(
color: Colors.white, fontSize: 20, fontWeight: FontWeight.w600),
titleMedium: TextStyle(
color: Colors.white, fontSize: 18, fontWeight: FontWeight.w500),
titleSmall: TextStyle(
color: Colors.white, fontSize: 16, fontWeight: FontWeight.w500),
bodyLarge: TextStyle(color: Colors.white, fontSize: 16),
bodyMedium: TextStyle(color: Colors.white70, fontSize: 14),
bodySmall: TextStyle(color: Colors.white60, fontSize: 12),
labelLarge: TextStyle(
color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500),
labelMedium: TextStyle(color: Colors.white70, fontSize: 12),
labelSmall: TextStyle(color: Colors.white60, fontSize: 10),
),
iconTheme: const IconThemeData(color: Colors.white),
dividerTheme: const DividerThemeData(
color: Color(0xFF2A2A2A),
thickness: 1,
),
switchTheme: SwitchThemeData(
thumbColor: MaterialStateProperty.resolveWith<Color?>(
(Set<MaterialState> states) {
if (states.contains(MaterialState.selected)) {
return Colors.blue;
}
return Colors.grey;
}),
trackColor: MaterialStateProperty.resolveWith<Color?>(
(Set<MaterialState> states) {
if (states.contains(MaterialState.selected)) {
return Colors.blue.withOpacity(0.5);
}
return Colors.grey.withOpacity(0.5);
}),
),
dialogTheme: DialogTheme(
backgroundColor: const Color(0xFF1E1E1E),
elevation: 8,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
titleTextStyle: const TextStyle(
color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold),
contentTextStyle: const TextStyle(color: Colors.white70, fontSize: 16),
),
floatingActionButtonTheme: const FloatingActionButtonThemeData(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
),
);
}
static const Color primaryBlack = Colors.black;
static const Color secondaryBlack = Color(0xFF121212);
static const Color tertiaryBlack = Color(0xFF1E1E1E);
static const Color dividerColor = Color(0xFF2A2A2A);
static const Color textPrimary = Colors.white;
static const Color textSecondary = Color(0xFFB3B3B3);
static const Color textTertiary = Color(0xFF808080);
static const Color accentBlue = Colors.blue;
static const Color accentBlueLight = Color(0xFF64B5F6);
static const Color errorRed = Color(0xFFCF6679);
static const Color successGreen = Color(0xFF4CAF50);
static const Color warningOrange = Color(0xFFFF9800);
static const TextStyle titleLarge = TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: textPrimary,
);
static const TextStyle titleMedium = TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: textPrimary,
);
static const TextStyle bodyLarge = TextStyle(
fontSize: 16,
color: textPrimary,
);
static const TextStyle bodyMedium = TextStyle(
fontSize: 14,
color: textSecondary,
);
static const TextStyle caption = TextStyle(
fontSize: 12,
color: textTertiary,
);
static const TextStyle labelLarge = TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: textPrimary,
);
static const double spacingXS = 4.0;
static const double spacingS = 8.0;
static const double spacingM = 16.0;
static const double spacingL = 24.0;
static const double spacingXL = 32.0;
static const double spacingXXL = 48.0;
static const double radiusS = 4.0;
static const double radiusM = 8.0;
static const double radiusL = 12.0;
static const double radiusXL = 16.0;
static List<BoxShadow> get cardShadow => [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
];
static List<BoxShadow> get buttonShadow => [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 4,
offset: const Offset(0, 2),
),
];
}

View File

@@ -0,0 +1,150 @@
import 'dart:convert';
import 'package:flutter/services.dart';
class LocoInfoUtil {
static final List<LocoInfo> _locoData = [];
static bool _initialized = false;
static Future<void> initialize() async {
if (_initialized) return;
try {
final csvData = await rootBundle.loadString('assets/loco_info.csv');
final lines = csvData.split('\n');
for (final line in lines) {
if (line.trim().isEmpty) continue;
final fields = _parseCsvLine(line);
if (fields.length >= 4) {
try {
final model = fields[0];
final start = int.parse(fields[1]);
final end = int.parse(fields[2]);
final owner = fields[3];
final alias = fields.length > 4 ? fields[4] : '';
final manufacturer = fields.length > 5 ? fields[5] : '';
_locoData.add(LocoInfo(
model: model,
start: start,
end: end,
owner: owner,
alias: alias,
manufacturer: manufacturer,
));
} catch (e) {}
}
}
_initialized = true;
} catch (e) {
_initialized = true;
}
}
static List<String> _parseCsvLine(String line) {
final fields = <String>[];
final buffer = StringBuffer();
bool inQuotes = false;
for (int i = 0; i < line.length; i++) {
final char = line[i];
if (char == '"') {
inQuotes = !inQuotes;
} else if (char == ',' && !inQuotes) {
fields.add(buffer.toString().trim());
buffer.clear();
} else {
buffer.write(char);
}
}
fields.add(buffer.toString().trim());
return fields;
}
static LocoInfo? findLocoInfo(String model, String number) {
if (!_initialized || model.isEmpty || number.isEmpty) {
return null;
}
try {
final cleanNumber = number.trim().replaceAll('-', '').replaceAll(' ', '');
final num = cleanNumber.length > 4
? int.parse(cleanNumber.substring(cleanNumber.length - 4))
: int.parse(cleanNumber);
for (final info in _locoData) {
if (info.model == model && num >= info.start && num <= info.end) {
return info;
}
}
} catch (e) {
return null;
}
return null;
}
static String? getLocoInfoDisplay(String model, String number) {
if (_locoData.isEmpty) return null;
final modelTrimmed = model.trim();
final numberTrimmed = number.trim();
if (modelTrimmed.isEmpty ||
numberTrimmed.isEmpty ||
numberTrimmed == "<NUL>") {
return null;
}
final cleanNumber = numberTrimmed.replaceAll('-', '').replaceAll(' ', '');
final numberSuffix = cleanNumber.length >= 4
? cleanNumber.substring(cleanNumber.length - 4)
: cleanNumber.padLeft(4, '0');
final numberInt = int.tryParse(numberSuffix);
if (numberInt == null) {
return null;
}
for (final info in _locoData) {
if (info.model == modelTrimmed &&
numberInt >= info.start &&
numberInt <= info.end) {
final buffer = StringBuffer();
buffer.write(info.owner);
if (info.alias.isNotEmpty) {
buffer.write(' - ${info.alias}');
}
if (info.manufacturer.isNotEmpty) {
buffer.write(' - ${info.manufacturer}');
}
return buffer.toString();
}
}
return null;
}
}
class LocoInfo {
final String model;
final int start;
final int end;
final String owner;
final String alias;
final String manufacturer;
LocoInfo({
required this.model,
required this.start,
required this.end,
required this.owner,
required this.alias,
required this.manufacturer,
});
}

View File

@@ -0,0 +1,54 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/services.dart';
class LocoTypeUtil {
static final LocoTypeUtil _instance = LocoTypeUtil._internal();
factory LocoTypeUtil() => _instance;
LocoTypeUtil._internal() {
_syncInitialize();
}
final Map<String, String> _locoTypeMap = {};
bool _isInitialized = false;
void _syncInitialize() {
try {
rootBundle.loadString('assets/loco_type_info.csv').then((csvData) {
final lines = const LineSplitter().convert(csvData);
for (final line in lines) {
final trimmedLine = line.trim();
if (trimmedLine.isEmpty) continue;
final parts = trimmedLine.split(',');
if (parts.length >= 2) {
final code = parts[0].trim();
final type = parts[1].trim();
_locoTypeMap[code] = type;
}
}
_isInitialized = true;
});
} catch (e) {}
}
@deprecated
Future<void> initialize() async {}
String? getLocoTypeByCode(String code) {
return _locoTypeMap[code];
}
String? getLocoTypeByLocoNumber(String locoNumber) {
if (locoNumber.length < 3) return null;
final prefix = locoNumber.substring(0, 3);
return getLocoTypeByCode(prefix);
}
Map<String, String> getAllMappings() {
return Map.from(_locoTypeMap);
}
bool get isInitialized => _isInitialized;
int get mappingCount => _locoTypeMap.length;
}

View File

@@ -0,0 +1,68 @@
import 'dart:convert';
import 'package:flutter/services.dart';
class TrainTypeUtil {
static final List<_TrainTypePattern> _patterns = [];
static bool _initialized = false;
static Future<void> initialize() async {
if (_initialized) return;
try {
final csvData =
await rootBundle.loadString('assets/train_number_info.csv');
final lines = csvData.split('\n');
for (final line in lines) {
if (line.trim().isEmpty) continue;
final firstQuoteEnd = line.indexOf('"', 1);
if (firstQuoteEnd > 0 && firstQuoteEnd < line.length - 1) {
final regex = line.substring(1, firstQuoteEnd);
final remainingPart = line.substring(firstQuoteEnd + 1).trim();
if (remainingPart.startsWith(',"') && remainingPart.endsWith('"')) {
final type = remainingPart.substring(2, remainingPart.length - 1);
try {
_patterns.add(_TrainTypePattern(RegExp(regex), type));
} catch (e) {}
}
}
}
_initialized = true;
} catch (e) {
_initialized = true;
}
}
static String? getTrainType(String lbjClass, String train) {
if (!_initialized) {
return null;
}
final lbjClassTrimmed = lbjClass.trim();
final trainTrimmed = train.trim();
if (trainTrimmed.isEmpty || trainTrimmed == "<NUL>") {
return null;
}
final actualTrain =
lbjClassTrimmed == "NA" ? trainTrimmed : lbjClassTrimmed + trainTrimmed;
for (final pattern in _patterns) {
if (pattern.regex.hasMatch(actualTrain)) {
return pattern.type;
}
}
return null;
}
}
class _TrainTypePattern {
final RegExp regex;
final String type;
_TrainTypePattern(this.regex, this.type);
}