init
This commit is contained in:
35
lib/main.dart
Normal file
35
lib/main.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
60
lib/models/merged_record.dart
Normal file
60
lib/models/merged_record.dart
Normal 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);
|
||||
}
|
||||
301
lib/models/train_record.dart
Normal file
301
lib/models/train_record.dart
Normal 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;
|
||||
}
|
||||
566
lib/screens/history_screen.dart
Normal file
566
lib/screens/history_screen.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
398
lib/screens/main_screen.dart
Normal file
398
lib/screens/main_screen.dart
Normal 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
640
lib/screens/map_screen.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
805
lib/screens/settings_screen.dart
Normal file
805
lib/screens/settings_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
361
lib/services/ble_service.dart
Normal file
361
lib/services/ble_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
320
lib/services/database_service.dart
Normal file
320
lib/services/database_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
17
lib/services/loco_type_service.dart
Normal file
17
lib/services/loco_type_service.dart
Normal 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;
|
||||
}
|
||||
85
lib/services/merge_service.dart
Normal file
85
lib/services/merge_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
135
lib/services/notification_service.dart
Normal file
135
lib/services/notification_service.dart
Normal 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
175
lib/themes/app_theme.dart
Normal 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),
|
||||
),
|
||||
];
|
||||
}
|
||||
150
lib/util/loco_info_util.dart
Normal file
150
lib/util/loco_info_util.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
54
lib/util/loco_type_util.dart
Normal file
54
lib/util/loco_type_util.dart
Normal 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;
|
||||
}
|
||||
68
lib/util/train_type_util.dart
Normal file
68
lib/util/train_type_util.dart
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user