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

View File

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

View File

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

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

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

View File

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