init
This commit is contained in:
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user