9 Commits

Author SHA1 Message Date
Nedifinita
64401a6ce9 feat: add the function to hide records that are only valid for time. 2025-09-25 22:52:19 +08:00
Nedifinita
72f9dfe17b fix: repair record processing logic 2025-09-25 22:25:51 +08:00
Nedifinita
bf850eed38 refactor: optimize record card rendering logic and animation effects 2025-09-25 22:01:25 +08:00
Nedifinita
56689fc993 feat: optimize record list 2025-09-25 21:45:52 +08:00
Nedifinita
ba373f749a feat: optimize Bluetooth connection status display 2025-09-25 00:44:03 +08:00
Nedifinita
23ab5ec746 feat: add background services and map status management 2025-09-24 23:36:55 +08:00
Nedifinita
10825171fd fix: solve the map state management problem when expanding/collapsed. 2025-09-24 17:39:52 +08:00
Nedifinita
1b05a6092c perf 2025-09-24 17:02:04 +08:00
Nedifinita
5141af58ac feat: optimize map zoom calculation and added location update 2025-09-24 16:31:57 +08:00
18 changed files with 1631 additions and 207 deletions

View File

@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.BLUETOOTH"/> <uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" android:usesPermissionFlags="neverForLocation"/> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" android:usesPermissionFlags="neverForLocation"/>
@@ -14,10 +15,11 @@
<uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/> <uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>
<application <application
android:label="lbjconsole" android:label="LBJ Console"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity
@@ -47,6 +49,15 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<!-- 前台服务配置 -->
<service
android:name="id.flutter.flutter_background_service.BackgroundService"
android:foregroundServiceType="connectedDevice|dataSync"
android:exported="false"
android:stopWithTask="false"
android:enabled="true"
tools:replace="android:exported"/>
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and

View File

@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Lbjconsole</string> <string>LBJ Console</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@@ -13,7 +13,7 @@
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>lbjconsole</string> <string>LBJ Console</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>

View File

@@ -1,14 +1,20 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:lbjconsole/screens/main_screen.dart'; import 'package:lbjconsole/screens/main_screen.dart';
import 'package:lbjconsole/util/train_type_util.dart'; import 'package:lbjconsole/util/train_type_util.dart';
import 'package:lbjconsole/util/loco_info_util.dart'; import 'package:lbjconsole/util/loco_info_util.dart';
import 'package:lbjconsole/util/loco_type_util.dart'; import 'package:lbjconsole/util/loco_type_util.dart';
import 'package:lbjconsole/services/loco_type_service.dart'; import 'package:lbjconsole/services/loco_type_service.dart';
import 'package:lbjconsole/services/database_service.dart'; import 'package:lbjconsole/services/database_service.dart';
import 'package:lbjconsole/services/background_service.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await _initializeNotifications();
await BackgroundService.initialize();
await Future.wait([ await Future.wait([
TrainTypeUtil.initialize(), TrainTypeUtil.initialize(),
LocoInfoUtil.initialize(), LocoInfoUtil.initialize(),
@@ -18,6 +24,19 @@ void main() async {
runApp(const LBJReceiverApp()); runApp(const LBJReceiverApp());
} }
Future<void> _initializeNotifications() async {
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
const InitializationSettings initializationSettings = InitializationSettings(
android: initializationSettingsAndroid,
);
await flutterLocalNotificationsPlugin.initialize(initializationSettings);
}
class LBJReceiverApp extends StatelessWidget { class LBJReceiverApp extends StatelessWidget {
const LBJReceiverApp({super.key}); const LBJReceiverApp({super.key});

65
lib/models/map_state.dart Normal file
View File

@@ -0,0 +1,65 @@
class MapState {
final double zoom;
final double centerLat;
final double centerLng;
final double bearing;
MapState({
required this.zoom,
required this.centerLat,
required this.centerLng,
required this.bearing,
});
Map<String, dynamic> toJson() {
return {
'zoom': zoom,
'centerLat': centerLat,
'centerLng': centerLng,
'bearing': bearing,
};
}
factory MapState.fromJson(Map<String, dynamic> json) {
return MapState(
zoom: json['zoom']?.toDouble() ?? 10.0,
centerLat: json['centerLat']?.toDouble() ?? 39.9042,
centerLng: json['centerLng']?.toDouble() ?? 116.4074,
bearing: json['bearing']?.toDouble() ?? 0.0,
);
}
MapState copyWith({
double? zoom,
double? centerLat,
double? centerLng,
double? bearing,
}) {
return MapState(
zoom: zoom ?? this.zoom,
centerLat: centerLat ?? this.centerLat,
centerLng: centerLng ?? this.centerLng,
bearing: bearing ?? this.bearing,
);
}
@override
String toString() {
return 'MapState(zoom: ' + zoom.toString() + ', centerLat: ' + centerLat.toString() + ', centerLng: ' + centerLng.toString() + ', bearing: ' + bearing.toString() + ')';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is MapState &&
other.zoom == zoom &&
other.centerLat == centerLat &&
other.centerLng == centerLng &&
other.bearing == bearing;
}
@override
int get hashCode {
return zoom.hashCode ^ centerLat.hashCode ^ centerLng.hashCode ^ bearing.hashCode;
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -10,8 +10,161 @@ import 'package:lbjconsole/screens/settings_screen.dart';
import 'package:lbjconsole/services/ble_service.dart'; import 'package:lbjconsole/services/ble_service.dart';
import 'package:lbjconsole/services/database_service.dart'; import 'package:lbjconsole/services/database_service.dart';
import 'package:lbjconsole/services/notification_service.dart'; import 'package:lbjconsole/services/notification_service.dart';
import 'package:lbjconsole/services/background_service.dart';
import 'package:lbjconsole/themes/app_theme.dart'; import 'package:lbjconsole/themes/app_theme.dart';
class _ConnectionStatusWidget extends StatefulWidget {
final BLEService bleService;
final DateTime? lastReceivedTime;
const _ConnectionStatusWidget({
required this.bleService,
required this.lastReceivedTime,
});
@override
State<_ConnectionStatusWidget> createState() =>
_ConnectionStatusWidgetState();
}
class _ConnectionStatusWidgetState extends State<_ConnectionStatusWidget> {
StreamSubscription? _connectionSubscription;
String _deviceStatus = "未连接";
bool _isConnected = false;
@override
void initState() {
super.initState();
_connectionSubscription =
widget.bleService.connectionStream.listen((connected) {
if (mounted) {
setState(() {
_isConnected = connected;
_deviceStatus = connected ? "已连接" : "未连接";
});
}
});
_isConnected = widget.bleService.isConnected;
_deviceStatus = widget.bleService.deviceStatus;
}
@override
void dispose() {
_connectionSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Row(
children: [
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.lastReceivedTime == null || !_isConnected) ...[
Text(_deviceStatus,
style: const TextStyle(color: Colors.white70, fontSize: 12)),
],
_LastReceivedTimeWidget(
lastReceivedTime: widget.lastReceivedTime,
isConnected: _isConnected,
),
],
),
const SizedBox(width: 8),
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: _isConnected ? Colors.green : Colors.red,
shape: BoxShape.circle,
),
),
],
);
}
}
class _LastReceivedTimeWidget extends StatefulWidget {
final DateTime? lastReceivedTime;
final bool isConnected;
const _LastReceivedTimeWidget({
required this.lastReceivedTime,
required this.isConnected,
});
@override
State<_LastReceivedTimeWidget> createState() =>
_LastReceivedTimeWidgetState();
}
class _LastReceivedTimeWidgetState extends State<_LastReceivedTimeWidget> {
Timer? _timer;
@override
void initState() {
super.initState();
_startTimer();
}
@override
void didUpdateWidget(_LastReceivedTimeWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.lastReceivedTime != widget.lastReceivedTime ||
oldWidget.isConnected != widget.isConnected) {
_startTimer();
}
}
void _startTimer() {
_timer?.cancel();
if (widget.lastReceivedTime != null && widget.isConnected) {
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (mounted) {
setState(() {});
}
});
}
}
String _formatTime() {
if (widget.lastReceivedTime == null) return '';
final now = DateTime.now();
final difference = now.difference(widget.lastReceivedTime!);
if (difference.inDays > 0) {
return '${difference.inDays}天前';
} else if (difference.inHours > 0) {
return '${difference.inHours}小时前';
} else if (difference.inMinutes > 0) {
return '${difference.inMinutes}分钟前';
} else {
return '${difference.inSeconds}秒前';
}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.lastReceivedTime == null || !widget.isConnected) {
return const SizedBox.shrink();
}
return Text(
_formatTime(),
style: const TextStyle(color: Colors.white70, fontSize: 12),
);
}
}
class MainScreen extends StatefulWidget { class MainScreen extends StatefulWidget {
const MainScreen({super.key}); const MainScreen({super.key});
@@ -27,7 +180,8 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
StreamSubscription? _connectionSubscription; StreamSubscription? _connectionSubscription;
StreamSubscription? _dataSubscription; StreamSubscription? _dataSubscription;
StreamSubscription? _lastReceivedTimeSubscription;
DateTime? _lastReceivedTime;
bool _isHistoryEditMode = false; bool _isHistoryEditMode = false;
final GlobalKey<HistoryScreenState> _historyScreenKey = final GlobalKey<HistoryScreenState> _historyScreenKey =
GlobalKey<HistoryScreenState>(); GlobalKey<HistoryScreenState>();
@@ -39,12 +193,36 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
_bleService = BLEService(); _bleService = BLEService();
_bleService.initialize(); _bleService.initialize();
_initializeServices(); _initializeServices();
_checkAndStartBackgroundService();
_setupLastReceivedTimeListener();
}
Future<void> _checkAndStartBackgroundService() async {
final settings = await DatabaseService.instance.getAllSettings() ?? {};
final backgroundServiceEnabled =
(settings['backgroundServiceEnabled'] ?? 0) == 1;
if (backgroundServiceEnabled) {
await BackgroundService.startService();
}
}
void _setupLastReceivedTimeListener() {
_lastReceivedTimeSubscription =
_bleService.lastReceivedTimeStream.listen((time) {
if (mounted) {
setState(() {
_lastReceivedTime = time;
});
}
});
} }
@override @override
void dispose() { void dispose() {
_connectionSubscription?.cancel(); _connectionSubscription?.cancel();
_dataSubscription?.cancel(); _dataSubscription?.cancel();
_lastReceivedTimeSubscription?.cancel();
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
super.dispose(); super.dispose();
} }
@@ -59,14 +237,10 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
Future<void> _initializeServices() async { Future<void> _initializeServices() async {
await _notificationService.initialize(); await _notificationService.initialize();
_connectionSubscription = _bleService.connectionStream.listen((_) {
if (mounted) setState(() {});
});
_dataSubscription = _bleService.dataStream.listen((record) { _dataSubscription = _bleService.dataStream.listen((record) {
_notificationService.showTrainNotification(record); _notificationService.showTrainNotification(record);
if (_historyScreenKey.currentState != null) { if (_historyScreenKey.currentState != null) {
_historyScreenKey.currentState!.loadRecords(scrollToTop: true); _historyScreenKey.currentState!.addNewRecord(record);
} }
}); });
} }
@@ -122,17 +296,10 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
actions: [ actions: [
Row( Row(
children: [ children: [
Container( _ConnectionStatusWidget(
width: 8, bleService: _bleService,
height: 8, lastReceivedTime: _lastReceivedTime,
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( IconButton(
icon: const Icon(Icons.bluetooth, color: Colors.white), icon: const Icon(Icons.bluetooth, color: Colors.white),
onPressed: _showConnectionDialog, onPressed: _showConnectionDialog,
@@ -230,7 +397,7 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
selectedIndex: _currentIndex, selectedIndex: _currentIndex,
onDestinationSelected: (index) { onDestinationSelected: (index) {
if (_currentIndex == 2 && index == 0) { if (_currentIndex == 2 && index == 0) {
_historyScreenKey.currentState?.loadRecords(); _historyScreenKey.currentState?.reloadRecords();
} }
setState(() { setState(() {
if (_isHistoryEditMode) _isHistoryEditMode = false; if (_isHistoryEditMode) _isHistoryEditMode = false;
@@ -263,6 +430,8 @@ class _PixelPerfectBluetoothDialogState
List<BluetoothDevice> _devices = []; List<BluetoothDevice> _devices = [];
_ScanState _scanState = _ScanState.initial; _ScanState _scanState = _ScanState.initial;
StreamSubscription? _connectionSubscription; StreamSubscription? _connectionSubscription;
StreamSubscription? _lastReceivedTimeSubscription;
DateTime? _lastReceivedTime;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -277,6 +446,7 @@ class _PixelPerfectBluetoothDialogState
@override @override
void dispose() { void dispose() {
_connectionSubscription?.cancel(); _connectionSubscription?.cancel();
_lastReceivedTimeSubscription?.cancel();
super.dispose(); super.dispose();
} }
@@ -306,6 +476,17 @@ class _PixelPerfectBluetoothDialogState
await widget.bleService.disconnect(); await widget.bleService.disconnect();
} }
void _setupLastReceivedTimeListener() {
_lastReceivedTimeSubscription =
widget.bleService.lastReceivedTimeStream.listen((timestamp) {
if (mounted) {
setState(() {
_lastReceivedTime = timestamp;
});
}
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isConnected = widget.bleService.isConnected; final isConnected = widget.bleService.isConnected;
@@ -342,6 +523,13 @@ class _PixelPerfectBluetoothDialogState
Text(device?.remoteId.str ?? '', Text(device?.remoteId.str ?? '',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center), textAlign: TextAlign.center),
if (_lastReceivedTime != null) ...[
const SizedBox(height: 8),
_LastReceivedTimeWidget(
lastReceivedTime: _lastReceivedTime,
isConnected: widget.bleService.isConnected,
),
],
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: _disconnect, onPressed: _disconnect,

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
@@ -26,8 +27,8 @@ class _MapScreenState extends State<MapScreen> {
bool _isMapInitialized = false; bool _isMapInitialized = false;
bool _isFollowingLocation = false; bool _isFollowingLocation = false;
bool _isLocationPermissionGranted = false; bool _isLocationPermissionGranted = false;
Timer? _locationTimer;
static const LatLng _defaultPosition = LatLng(39.9042, 116.4074);
@override @override
void initState() { void initState() {
@@ -35,12 +36,13 @@ class _MapScreenState extends State<MapScreen> {
_initializeMap(); _initializeMap();
_loadTrainRecords(); _loadTrainRecords();
_loadSettings(); _loadSettings();
_requestLocationPermission(); _startLocationUpdates();
} }
@override @override
void dispose() { void dispose() {
_saveSettings(); _saveSettings();
_locationTimer?.cancel();
super.dispose(); super.dispose();
} }
@@ -49,6 +51,9 @@ class _MapScreenState extends State<MapScreen> {
Future<void> _requestLocationPermission() async { Future<void> _requestLocationPermission() async {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) { if (!serviceEnabled) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请开启定位服务')),
);
return; return;
} }
@@ -58,6 +63,9 @@ class _MapScreenState extends State<MapScreen> {
} }
if (permission == LocationPermission.deniedForever) { if (permission == LocationPermission.deniedForever) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('定位权限被拒绝,请在设置中开启')),
);
return; return;
} }
@@ -78,12 +86,39 @@ class _MapScreenState extends State<MapScreen> {
_userLocation = LatLng(position.latitude, position.longitude); _userLocation = LatLng(position.latitude, position.longitude);
}); });
if (!_isMapInitialized && _userLocation != null) { } catch (e) {
_mapController.move(_userLocation!, _currentZoom); }
}
} catch (e) {}
} }
void _startLocationUpdates() {
_requestLocationPermission();
_locationTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
if (_isLocationPermissionGranted) {
_getCurrentLocation();
}
});
}
Future<void> _forceUpdateLocation() async {
try {
Position position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.best,
);
final newLocation = LatLng(position.latitude, position.longitude);
setState(() {
_userLocation = newLocation;
});
_mapController.move(newLocation, 15.0);
} catch (e) {
}
}
Future<void> _loadSettings() async { Future<void> _loadSettings() async {
try { try {
final settings = await DatabaseService.instance.getAllSettings(); final settings = await DatabaseService.instance.getAllSettings();
@@ -159,13 +194,12 @@ class _MapScreenState extends State<MapScreen> {
} else if (_lastTrainLocation != null) { } else if (_lastTrainLocation != null) {
targetLocation = _lastTrainLocation; targetLocation = _lastTrainLocation;
} else { } else {
targetLocation = _defaultPosition; _isMapInitialized = true;
return;
} }
WidgetsBinding.instance.addPostFrameCallback((_) { _centerMap(targetLocation!, zoom: _currentZoom);
_centerMap(targetLocation!, zoom: _currentZoom); _isMapInitialized = true;
_isMapInitialized = true;
});
} }
void _centerMap(LatLng location, {double? zoom}) { void _centerMap(LatLng location, {double? zoom}) {
@@ -313,7 +347,7 @@ class _MapScreenState extends State<MapScreen> {
} }
void _centerToMyLocation() { void _centerToMyLocation() {
_centerMap(_lastTrainLocation ?? _defaultPosition, zoom: 15.0); _centerMap(_lastTrainLocation ?? const LatLng(39.9042, 116.4074), zoom: 15.0);
} }
void _centerToLastTrain() { void _centerToLastTrain() {
@@ -537,11 +571,12 @@ class _MapScreenState extends State<MapScreen> {
FlutterMap( FlutterMap(
mapController: _mapController, mapController: _mapController,
options: MapOptions( options: MapOptions(
initialCenter: _lastTrainLocation ?? _defaultPosition, initialCenter: _lastTrainLocation ?? const LatLng(39.9042, 116.4074),
initialZoom: _currentZoom, initialZoom: _currentZoom,
initialRotation: _currentRotation, initialRotation: _currentRotation,
minZoom: 4.0, minZoom: 4.0,
maxZoom: 18.0, maxZoom: 18.0,
onPositionChanged: (MapCamera camera, bool hasGesture) { onPositionChanged: (MapCamera camera, bool hasGesture) {
if (hasGesture) { if (hasGesture) {
setState(() { setState(() {
@@ -552,28 +587,6 @@ class _MapScreenState extends State<MapScreen> {
_saveSettings(); _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: [ children: [
TileLayer( TileLayer(
@@ -622,10 +635,7 @@ class _MapScreenState extends State<MapScreen> {
heroTag: 'myLocation', heroTag: 'myLocation',
backgroundColor: const Color(0xFF1E1E1E), backgroundColor: const Color(0xFF1E1E1E),
onPressed: () { onPressed: () {
_getCurrentLocation(); _forceUpdateLocation();
if (_userLocation != null) {
_centerMap(_userLocation!, zoom: 15.0);
}
}, },
child: const Icon(Icons.my_location, color: Colors.white), child: const Icon(Icons.my_location, color: Colors.white),
), ),

View File

@@ -5,6 +5,7 @@ import 'dart:io';
import 'package:lbjconsole/models/merged_record.dart'; import 'package:lbjconsole/models/merged_record.dart';
import 'package:lbjconsole/services/database_service.dart'; import 'package:lbjconsole/services/database_service.dart';
import 'package:lbjconsole/services/ble_service.dart'; import 'package:lbjconsole/services/ble_service.dart';
import 'package:lbjconsole/services/background_service.dart';
import 'package:lbjconsole/themes/app_theme.dart'; import 'package:lbjconsole/themes/app_theme.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@@ -31,6 +32,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _notificationsEnabled = true; bool _notificationsEnabled = true;
int _recordCount = 0; int _recordCount = 0;
bool _mergeRecordsEnabled = false; bool _mergeRecordsEnabled = false;
bool _hideTimeOnlyRecords = false;
GroupBy _groupBy = GroupBy.trainAndLoco; GroupBy _groupBy = GroupBy.trainAndLoco;
TimeWindow _timeWindow = TimeWindow.unlimited; TimeWindow _timeWindow = TimeWindow.unlimited;
@@ -60,6 +62,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
(settingsMap['backgroundServiceEnabled'] ?? 0) == 1; (settingsMap['backgroundServiceEnabled'] ?? 0) == 1;
_notificationsEnabled = (settingsMap['notificationEnabled'] ?? 1) == 1; _notificationsEnabled = (settingsMap['notificationEnabled'] ?? 1) == 1;
_mergeRecordsEnabled = settings.enabled; _mergeRecordsEnabled = settings.enabled;
_hideTimeOnlyRecords = (settingsMap['hideTimeOnlyRecords'] ?? 0) == 1;
_groupBy = settings.groupBy; _groupBy = settings.groupBy;
_timeWindow = settings.timeWindow; _timeWindow = settings.timeWindow;
}); });
@@ -81,6 +84,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
'backgroundServiceEnabled': _backgroundServiceEnabled ? 1 : 0, 'backgroundServiceEnabled': _backgroundServiceEnabled ? 1 : 0,
'notificationEnabled': _notificationsEnabled ? 1 : 0, 'notificationEnabled': _notificationsEnabled ? 1 : 0,
'mergeRecordsEnabled': _mergeRecordsEnabled ? 1 : 0, 'mergeRecordsEnabled': _mergeRecordsEnabled ? 1 : 0,
'hideTimeOnlyRecords': _hideTimeOnlyRecords ? 1 : 0,
'groupBy': _groupBy.name, 'groupBy': _groupBy.name,
'timeWindow': _timeWindow.name, 'timeWindow': _timeWindow.name,
}); });
@@ -196,11 +200,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
Switch( Switch(
value: _backgroundServiceEnabled, value: _backgroundServiceEnabled,
onChanged: (value) { onChanged: (value) async {
setState(() { setState(() {
_backgroundServiceEnabled = value; _backgroundServiceEnabled = value;
}); });
_saveSettings(); await _saveSettings();
if (value) {
await BackgroundService.startService();
} else {
await BackgroundService.stopService();
}
}, },
activeColor: Theme.of(context).colorScheme.primary, activeColor: Theme.of(context).colorScheme.primary,
), ),
@@ -229,6 +239,29 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
], ],
), ),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('隐藏只有时间有效的记录', style: AppTheme.bodyLarge),
Text('不显示只有时间信息的记录', style: AppTheme.caption),
],
),
Switch(
value: _hideTimeOnlyRecords,
onChanged: (value) {
setState(() {
_hideTimeOnlyRecords = value;
});
_saveSettings();
},
activeColor: Theme.of(context).colorScheme.primary,
),
],
),
], ],
), ),
), ),
@@ -388,9 +421,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
children: [ children: [
Row( Row(
children: [ children: [
Icon(Icons.share, color: Theme.of(context).colorScheme.primary), Icon(Icons.storage, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 12), const SizedBox(width: 12),
Text('数据分享', style: AppTheme.titleMedium), Text('数据管理', style: AppTheme.titleMedium),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -503,8 +536,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
} }
Future<void> _shareData() async { Future<void> _shareData() async {
final scaffoldMessenger = ScaffoldMessenger.of(context); final scaffoldMessenger = ScaffoldMessenger.of(context);
@@ -530,7 +561,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (exportedPath != null) { if (exportedPath != null) {
final file = File(exportedPath); final file = File(exportedPath);
final fileName = file.path.split(Platform.pathSeparator).last; final fileName = file.path.split(Platform.pathSeparator).last;
await Share.shareXFiles( await Share.shareXFiles(
[XFile(file.path)], [XFile(file.path)],
subject: 'LBJ Console Data', subject: 'LBJ Console Data',
@@ -735,7 +766,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (snapshot.hasData) { if (snapshot.hasData) {
return Text(snapshot.data!, style: AppTheme.bodyMedium); return Text(snapshot.data!, style: AppTheme.bodyMedium);
} else { } else {
return const Text('v0.1.3-flutter', style: AppTheme.bodyMedium); return const Text('v0.1.3-flutter',
style: AppTheme.bodyMedium);
} }
}, },
), ),

View File

@@ -0,0 +1,211 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:flutter_background_service/flutter_background_service.dart';
import 'package:flutter_background_service_android/flutter_background_service_android.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:lbjconsole/services/ble_service.dart';
const String _notificationChannelId = 'lbj_console_channel';
const String _notificationChannelName = 'LBJ Console 后台服务';
const String _notificationChannelDescription = '保持蓝牙连接稳定';
const int _notificationId = 114514;
class BackgroundService {
static final FlutterBackgroundService _service = FlutterBackgroundService();
static bool _isInitialized = false;
static Future<void> initialize() async {
if (_isInitialized) return;
final service = FlutterBackgroundService();
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
if (Platform.isAndroid) {
const AndroidNotificationChannel channel = AndroidNotificationChannel(
_notificationChannelId,
_notificationChannelName,
description: _notificationChannelDescription,
importance: Importance.low,
enableLights: false,
enableVibration: false,
playSound: false,
);
await flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()?.createNotificationChannel(channel);
}
await service.configure(
androidConfiguration: AndroidConfiguration(
onStart: _onStart,
autoStart: true,
isForegroundMode: true,
notificationChannelId: _notificationChannelId,
initialNotificationTitle: 'LBJ Console',
initialNotificationContent: '蓝牙连接监控中',
foregroundServiceNotificationId: _notificationId,
),
iosConfiguration: IosConfiguration(
autoStart: true,
onForeground: _onStart,
onBackground: _onIosBackground,
),
);
_isInitialized = true;
}
@pragma('vm:entry-point')
static void _onStart(ServiceInstance service) async {
DartPluginRegistrant.ensureInitialized();
if (service is AndroidServiceInstance) {
service.on('setAsForeground').listen((event) {
service.setAsForegroundService();
});
service.on('setAsBackground').listen((event) {
service.setAsBackgroundService();
});
}
service.on('stopService').listen((event) {
service.stopSelf();
});
BLEService().initialize();
if (service is AndroidServiceInstance) {
await Future.delayed(const Duration(seconds: 1));
if (await service.isForegroundService()) {
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
try {
const AndroidNotificationChannel channel = AndroidNotificationChannel(
_notificationChannelId,
_notificationChannelName,
description: _notificationChannelDescription,
importance: Importance.low,
enableLights: false,
enableVibration: false,
playSound: false,
);
await flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()?.createNotificationChannel(channel);
await flutterLocalNotificationsPlugin.show(
_notificationId,
'LBJ Console',
'蓝牙连接监控中',
NotificationDetails(
android: AndroidNotificationDetails(
_notificationChannelId,
_notificationChannelName,
channelDescription: _notificationChannelDescription,
icon: '@mipmap/ic_launcher',
ongoing: true,
autoCancel: false,
importance: Importance.low,
priority: Priority.low,
enableLights: false,
enableVibration: false,
playSound: false,
onlyAlertOnce: true,
setAsGroupSummary: false,
groupKey: 'lbj_console_group',
visibility: NotificationVisibility.public,
category: AndroidNotificationCategory.service,
),
),
);
print('前台服务通知显示成功');
} catch (e) {
print('前台服务通知显示失败: $e');
}
}
}
Timer.periodic(const Duration(seconds: 30), (timer) async {
if (service is AndroidServiceInstance) {
if (await service.isForegroundService()) {
try {
final bleService = BLEService();
final isConnected = bleService.isConnected;
final deviceStatus = bleService.deviceStatus;
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
await flutterLocalNotificationsPlugin.show(
_notificationId,
'LBJ Console',
isConnected ? '蓝牙已连接 - $deviceStatus' : '蓝牙未连接 - 自动重连中',
NotificationDetails(
android: AndroidNotificationDetails(
_notificationChannelId,
_notificationChannelName,
channelDescription: _notificationChannelDescription,
icon: '@mipmap/ic_launcher',
ongoing: true,
autoCancel: false,
importance: Importance.low,
priority: Priority.low,
enableLights: false,
enableVibration: false,
playSound: false,
onlyAlertOnce: true,
setAsGroupSummary: false,
groupKey: 'lbj_console_group',
visibility: NotificationVisibility.public,
category: AndroidNotificationCategory.service,
),
),
);
} catch (e) {
print('前台服务通知更新失败: $e');
}
}
}
});
}
@pragma('vm:entry-point')
static Future<bool> _onIosBackground(ServiceInstance service) async {
return true;
}
static Future<void> startService() async {
await initialize();
final service = FlutterBackgroundService();
if (Platform.isAndroid) {
final isRunning = await service.isRunning();
if (!isRunning) {
service.startService();
}
} else if (Platform.isIOS) {
service.startService();
}
}
static Future<void> stopService() async {
final service = FlutterBackgroundService();
service.invoke('stopService');
}
static Future<bool> isRunning() async {
final service = FlutterBackgroundService();
return await service.isRunning();
}
static void setForegroundMode(bool isForeground) {
final service = FlutterBackgroundService();
if (isForeground) {
service.invoke('setAsForeground');
} else {
service.invoke('setAsBackground');
}
}
}

View File

@@ -27,14 +27,19 @@ class BLEService {
StreamController<TrainRecord>.broadcast(); StreamController<TrainRecord>.broadcast();
final StreamController<bool> _connectionController = final StreamController<bool> _connectionController =
StreamController<bool>.broadcast(); StreamController<bool>.broadcast();
final StreamController<DateTime?> _lastReceivedTimeController =
StreamController<DateTime?>.broadcast();
Stream<String> get statusStream => _statusController.stream; Stream<String> get statusStream => _statusController.stream;
Stream<TrainRecord> get dataStream => _dataController.stream; Stream<TrainRecord> get dataStream => _dataController.stream;
Stream<bool> get connectionStream => _connectionController.stream; Stream<bool> get connectionStream => _connectionController.stream;
Stream<DateTime?> get lastReceivedTimeStream =>
_lastReceivedTimeController.stream;
String _deviceStatus = "未连接"; String _deviceStatus = "未连接";
String? _lastKnownDeviceAddress; String? _lastKnownDeviceAddress;
String _targetDeviceName = "LBJReceiver"; String _targetDeviceName = "LBJReceiver";
DateTime? _lastReceivedTime;
bool _isConnecting = false; bool _isConnecting = false;
bool _isManualDisconnect = false; bool _isManualDisconnect = false;
@@ -69,8 +74,7 @@ class BLEService {
if (settings != null) { if (settings != null) {
_targetDeviceName = settings['deviceName'] ?? 'LBJReceiver'; _targetDeviceName = settings['deviceName'] ?? 'LBJReceiver';
} }
} catch (e) { } catch (e) {}
}
} }
void ensureConnection() { void ensureConnection() {
@@ -315,6 +319,9 @@ class BLEService {
'${now.millisecondsSinceEpoch}_${Random().nextInt(9999)}'; '${now.millisecondsSinceEpoch}_${Random().nextInt(9999)}';
recordData['receivedTimestamp'] = now.millisecondsSinceEpoch; recordData['receivedTimestamp'] = now.millisecondsSinceEpoch;
_lastReceivedTime = now;
_lastReceivedTimeController.add(_lastReceivedTime);
final trainRecord = TrainRecord.fromJson(recordData); final trainRecord = TrainRecord.fromJson(recordData);
_dataController.add(trainRecord); _dataController.add(trainRecord);
DatabaseService.instance.insertRecord(trainRecord); DatabaseService.instance.insertRecord(trainRecord);
@@ -331,6 +338,8 @@ class BLEService {
_deviceStatus = status; _deviceStatus = status;
_connectedDevice = null; _connectedDevice = null;
_characteristic = null; _characteristic = null;
_lastReceivedTime = null;
_lastReceivedTimeController.add(null);
} }
_statusController.add(_deviceStatus); _statusController.add(_deviceStatus);
_connectionController.add(connected); _connectionController.add(connected);
@@ -357,5 +366,6 @@ class BLEService {
_statusController.close(); _statusController.close();
_dataController.close(); _dataController.close();
_connectionController.close(); _connectionController.close();
_lastReceivedTimeController.close();
} }
} }

View File

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

View File

@@ -0,0 +1,113 @@
import 'dart:convert';
import 'package:sqflite/sqflite.dart';
import 'package:lbjconsole/models/map_state.dart';
import 'database_service.dart';
class MapStateService {
static final MapStateService instance = MapStateService._internal();
factory MapStateService() => instance;
MapStateService._internal();
static const String _tableName = 'record_map_states';
final Map<String, MapState> _memoryCache = {};
Future<void> _ensureTableExists() async {
final db = await DatabaseService.instance.database;
await db.execute('''
CREATE TABLE IF NOT EXISTS $_tableName (
key TEXT PRIMARY KEY,
state TEXT NOT NULL,
updated_at INTEGER NOT NULL
)
''');
}
String getSingleRecordMapKey(String recordId) {
return "${recordId}_record_map";
}
String getMergedRecordMapKey(String groupKey) {
return "${groupKey}_group_map";
}
Future<void> saveMapState(String key, MapState state) async {
try {
_memoryCache[key] = state;
final db = await DatabaseService.instance.database;
await _ensureTableExists();
await db.insert(
_tableName,
{
'key': key,
'state': jsonEncode(state.toJson()),
'updated_at': DateTime.now().millisecondsSinceEpoch,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
} catch (e) {
print('保存地图状态失败: $e');
}
}
Future<MapState?> getMapState(String key) async {
if (_memoryCache.containsKey(key)) {
return _memoryCache[key];
}
try {
final db = await DatabaseService.instance.database;
await _ensureTableExists();
final result = await db.query(
_tableName,
where: 'key = ?',
whereArgs: [key],
limit: 1,
);
if (result.isNotEmpty) {
final stateJson = jsonDecode(result.first['state'] as String);
final state = MapState.fromJson(stateJson);
_memoryCache[key] = state;
return state;
}
} catch (e) {
print('读取地图状态失败: $e');
}
return null;
}
Future<void> deleteMapState(String key) async {
_memoryCache.remove(key);
try {
final db = await DatabaseService.instance.database;
await db.delete(
_tableName,
where: 'key = ?',
whereArgs: [key],
);
} catch (e) {
print('删除地图状态失败: $e');
}
}
Future<void> clearAllMapStates() async {
_memoryCache.clear();
try {
final db = await DatabaseService.instance.database;
await db.delete(_tableName);
} catch (e) {
print('清空地图状态失败: $e');
}
}
void clearMemoryCache() {
_memoryCache.clear();
}
}

View File

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

View File

@@ -61,7 +61,7 @@ class NotificationService {
return; return;
} }
final String title = '列车信息更新'; final String title = '列车信息';
final String body = _buildNotificationContent(record); final String body = _buildNotificationContent(record);
final AndroidNotificationDetails androidPlatformChannelSpecifics = final AndroidNotificationDetails androidPlatformChannelSpecifics =

View File

@@ -5,7 +5,7 @@
// 'flutter create' template. // 'flutter create' template.
// The application's name. By default this is also the title of the Flutter window. // The application's name. By default this is also the title of the Flutter window.
PRODUCT_NAME = lbjconsole PRODUCT_NAME = LBJ Console
// The application's bundle identifier // The application's bundle identifier
PRODUCT_BUNDLE_IDENTIFIER = org.noxylva.lbjconsole PRODUCT_BUNDLE_IDENTIFIER = org.noxylva.lbjconsole

View File

@@ -278,6 +278,38 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_background_service:
dependency: "direct main"
description:
name: flutter_background_service
sha256: "70a1c185b1fa1a44f8f14ecd6c86f6e50366e3562f00b2fa5a54df39b3324d3d"
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.1.0"
flutter_background_service_android:
dependency: transitive
description:
name: flutter_background_service_android
sha256: ca0793d4cd19f1e194a130918401a3d0b1076c81236f7273458ae96987944a87
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.3.1"
flutter_background_service_ios:
dependency: transitive
description:
name: flutter_background_service_ios
sha256: "6037ffd45c4d019dab0975c7feb1d31012dd697e25edc05505a4a9b0c7dc9fba"
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.0.3"
flutter_background_service_platform_interface:
dependency: transitive
description:
name: flutter_background_service_platform_interface
sha256: ca74aa95789a8304f4d3f57f07ba404faa86bed6e415f83e8edea6ad8b904a41
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.1.2"
flutter_blue_plus: flutter_blue_plus:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -888,6 +920,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "0.28.0" version: "0.28.0"
scrollview_observer:
dependency: "direct main"
description:
name: scrollview_observer
sha256: c2f713509f18f88f637b2084b47a90c91fb1ef066d5d82d2cf3194d8509dc6ab
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.26.2"
share_plus: share_plus:
dependency: "direct main" dependency: "direct main"
description: description:

View File

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