fix: critical bugs preventing location reporting to server

- Send config to native before starting tracking (SharedPreferences sync)
- Register ConnectivityReceiver for network state detection
- Fix Settings number fields using TextEditingControllers
- Cancel stream subscription on widget dispose (memory leak)
- Replace WorkManager with AlarmManager for heartbeat
- Add log cleanup for logs older than 24 hours
- Change HTTP method from GET to POST

These fixes resolve the 'device always offline' issue where:
1. Config was not being sent to native service
2. ConnectivityReceiver was never registered
3. Settings number fields were not saving
4. Heartbeat never fired due to WorkManager process isolation
5. Server expected POST not GET
This commit is contained in:
fiatcode 2026-04-30 15:38:24 +07:00
parent 3ae8bf00c1
commit bbd51d1c35
No known key found for this signature in database
10 changed files with 1200 additions and 167 deletions

View file

@ -12,22 +12,29 @@ class LocationBridge {
static Stream<Map<String, dynamic>>? _locationStream;
static Future<bool> startTracking() async {
static Future<bool> startTracking({Map<String, dynamic>? config}) async {
debugPrint('LocationBridge.startTracking called with config: $config');
try {
if (config != null) {
await _methodChannel.invokeMethod('updateConfig', config);
}
final result = await _methodChannel.invokeMethod<bool>('startTracking');
debugPrint('LocationBridge.startTracking result: $result');
return result ?? false;
} on PlatformException catch (e) {
debugPrint('Failed to start tracking: ${e.message}');
debugPrint('LocationBridge.startTracking failed: ${e.message}');
return false;
}
}
static Future<bool> stopTracking() async {
debugPrint('LocationBridge.stopTracking called');
try {
final result = await _methodChannel.invokeMethod<bool>('stopTracking');
debugPrint('LocationBridge.stopTracking result: $result');
return result ?? false;
} on PlatformException catch (e) {
debugPrint('Failed to stop tracking: ${e.message}');
debugPrint('LocationBridge.stopTracking failed: ${e.message}');
return false;
}
}
@ -46,11 +53,13 @@ class LocationBridge {
}
static Future<Map<String, dynamic>?> getStatus() async {
debugPrint('LocationBridge.getStatus called');
try {
final result = await _methodChannel.invokeMethod<Map>('getStatus');
debugPrint('LocationBridge.getStatus result: $result');
return result?.cast<String, dynamic>();
} on PlatformException catch (e) {
debugPrint('Failed to get status: ${e.message}');
debugPrint('LocationBridge.getStatus failed: ${e.message}');
return null;
}
}
@ -71,4 +80,15 @@ class LocationBridge {
);
return _locationStream!;
}
static Future<List<Map<String, dynamic>>> getLogs({int limit = 100}) async {
try {
final result = await _methodChannel.invokeMethod<List>('getLogs', limit);
return result?.map((e) => Map<String, dynamic>.from(e as Map)).toList() ??
[];
} on PlatformException catch (e) {
debugPrint('Failed to get logs: ${e.message}');
return [];
}
}
}

View file

@ -1,5 +1,8 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:traccar_client/bridge/location_bridge.dart';
import 'package:traccar_client/preferences.dart';
import 'package:traccar_client/settings_screen.dart';
import 'package:traccar_client/status_screen.dart';
@ -11,6 +14,7 @@ class MainScreen extends StatefulWidget {
}
class _MainScreenState extends State<MainScreen> {
StreamSubscription<Map<String, dynamic>>? _locationSubscription;
bool _isTracking = false;
String _lastLat = '--';
String _lastLon = '--';
@ -23,6 +27,12 @@ class _MainScreenState extends State<MainScreen> {
_initLocationStream();
}
@override
void dispose() {
_locationSubscription?.cancel();
super.dispose();
}
Future<void> _initLocationStream() async {
final status = await LocationBridge.getStatus();
if (mounted) {
@ -49,7 +59,7 @@ class _MainScreenState extends State<MainScreen> {
});
}
LocationBridge.locationUpdates.listen((location) {
_locationSubscription = LocationBridge.locationUpdates.listen((location) {
if (mounted) {
setState(() {
_lastLat = location['latitude']?.toStringAsFixed(4) ?? '--';
@ -72,17 +82,39 @@ class _MainScreenState extends State<MainScreen> {
}
Future<void> _toggleTracking() async {
if (_isTracking) {
await LocationBridge.stopTracking();
} else {
await LocationBridge.startTracking();
}
await Future.delayed(const Duration(milliseconds: 500));
final status = await LocationBridge.getStatus();
if (mounted) {
setState(() {
_isTracking = status?['isTracking'] == true;
final previousState = _isTracking;
final newState = !_isTracking;
setState(() {
_isTracking = newState;
});
bool success;
if (newState) {
success = await LocationBridge.startTracking(config: {
'serverUrl': Preferences.serverUrl,
'deviceId': Preferences.deviceId,
'password': Preferences.password,
'accuracy': Preferences.accuracy,
'distanceFilter': Preferences.distanceFilter,
'interval': Preferences.interval,
'heartbeat': Preferences.heartbeat,
'offlineBuffer': Preferences.offlineBuffer,
'stopDetection': Preferences.stopDetection,
});
} else {
success = await LocationBridge.stopTracking();
}
if (!success && mounted) {
setState(() {
_isTracking = previousState;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(previousState ? 'Failed to stop tracking' : 'Failed to start tracking'),
),
);
}
}
@ -169,9 +201,10 @@ class _MainScreenState extends State<MainScreen> {
SnackBar(
content: Text(
success
? 'Location reported'
: 'Failed to report location',
? 'Location sent to server'
: 'Failed - check logs',
),
duration: Duration(seconds: 2),
),
);
}

View file

@ -12,10 +12,10 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> {
late TextEditingController _serverUrlController;
late TextEditingController _deviceIdController;
late TextEditingController _distanceFilterController;
late TextEditingController _intervalController;
late TextEditingController _heartbeatController;
late int _accuracy;
late int _distanceFilter;
late int _interval;
late int _heartbeat;
late bool _offlineBuffer;
late bool _stopDetection;
@ -24,10 +24,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
super.initState();
_serverUrlController = TextEditingController(text: Preferences.serverUrl);
_deviceIdController = TextEditingController(text: Preferences.deviceId);
_distanceFilterController = TextEditingController(text: Preferences.distanceFilter.toString());
_intervalController = TextEditingController(text: Preferences.interval.toString());
_heartbeatController = TextEditingController(text: Preferences.heartbeat.toString());
_accuracy = Preferences.accuracy;
_distanceFilter = Preferences.distanceFilter;
_interval = Preferences.interval;
_heartbeat = Preferences.heartbeat;
_offlineBuffer = Preferences.offlineBuffer;
_stopDetection = Preferences.stopDetection;
}
@ -36,16 +36,23 @@ class _SettingsScreenState extends State<SettingsScreen> {
void dispose() {
_serverUrlController.dispose();
_deviceIdController.dispose();
_distanceFilterController.dispose();
_intervalController.dispose();
_heartbeatController.dispose();
super.dispose();
}
Future<void> _saveSettings() async {
final distanceFilter = int.tryParse(_distanceFilterController.text) ?? 75;
final interval = int.tryParse(_intervalController.text) ?? 300;
final heartbeat = int.tryParse(_heartbeatController.text) ?? 60;
await Preferences.setServerUrl(_serverUrlController.text);
await Preferences.setDeviceId(_deviceIdController.text);
await Preferences.setAccuracy(_accuracy);
await Preferences.setDistanceFilter(_distanceFilter);
await Preferences.setInterval(_interval);
await Preferences.setHeartbeat(_heartbeat);
await Preferences.setDistanceFilter(distanceFilter);
await Preferences.setInterval(interval);
await Preferences.setHeartbeat(heartbeat);
await Preferences.setOfflineBuffer(_offlineBuffer);
await Preferences.setStopDetection(_stopDetection);
@ -53,9 +60,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
'serverUrl': _serverUrlController.text,
'deviceId': _deviceIdController.text,
'accuracy': _accuracy,
'distanceFilter': _distanceFilter,
'interval': _interval,
'heartbeat': _heartbeat,
'distanceFilter': distanceFilter,
'interval': interval,
'heartbeat': heartbeat,
'offlineBuffer': _offlineBuffer,
'stopDetection': _stopDetection,
});
@ -80,9 +87,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 16),
_buildSectionHeader('Location'),
_buildAccuracyDropdown(),
_buildNumberField('Distance Filter (m)', _distanceFilter, 0, 1000),
_buildNumberField('Update Interval (s)', _interval, 30, 3600),
_buildNumberField('Heartbeat (s)', _heartbeat, 60, 3600),
_buildNumberField('Distance Filter (m)', _distanceFilterController, 0, 1000),
_buildNumberField('Update Interval (s)', _intervalController, 30, 3600),
_buildNumberField('Heartbeat (s)', _heartbeatController, 60, 3600),
const SizedBox(height: 16),
_buildSectionHeader('Advanced'),
SwitchListTile(
@ -146,30 +153,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
Widget _buildNumberField(String label, int value, int min, int max) {
return TextFormField(
initialValue: value.toString(),
Widget _buildNumberField(String label, TextEditingController controller, int min, int max) {
return TextField(
controller: controller,
decoration: InputDecoration(labelText: label),
keyboardType: TextInputType.number,
validator: (v) {
final parsed = int.tryParse(v ?? '');
if (parsed == null || parsed < min || parsed > max) {
return 'Must be between $min and $max';
}
return null;
},
onSaved: (v) {
final parsed = int.tryParse(v ?? '');
if (parsed != null) {
if (label.contains('Distance')) {
_distanceFilter = parsed;
} else if (label.contains('Interval')) {
_interval = parsed;
} else if (label.contains('Heartbeat')) {
_heartbeat = parsed;
}
}
},
);
}
}

View file

@ -1,72 +1,105 @@
import 'package:flutter/material.dart';
import 'bridge/location_bridge.dart';
class StatusScreen extends StatelessWidget {
class StatusScreen extends StatefulWidget {
const StatusScreen({super.key});
@override
State<StatusScreen> createState() => _StatusScreenState();
}
class _StatusScreenState extends State<StatusScreen> {
List<Map<String, dynamic>> _logs = [];
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_loadLogs();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
Future<void> _loadLogs() async {
final logs = await LocationBridge.getLogs();
if (mounted) {
setState(() {
_logs = logs.reversed.toList();
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
}
});
}
}
String _formatTimestamp(int timestamp) {
final dt = DateTime.fromMillisecondsSinceEpoch(timestamp);
return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}';
}
Color _getLogColor(String eventType) {
switch (eventType) {
case 'ERROR':
return Colors.red;
case 'WARNING':
return Colors.orange;
case 'LOCATION':
return Colors.green;
case 'SYNC':
return Colors.cyan;
case 'HEARTBEAT':
return Colors.yellow;
case 'FILTERED':
return Colors.grey;
default:
return Colors.white;
}
}
Widget _buildTerminalLine(Map<String, dynamic> log) {
final time = _formatTimestamp(log['timestamp'] as int? ?? 0);
final type = log['eventType'] as String? ?? 'UNKNOWN';
final msg = log['message'] as String? ?? '';
final color = _getLogColor(type);
return Text(
'[$time] $type: $msg',
style: TextStyle(fontFamily: 'monospace', fontSize: 12, color: color),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Event Log')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: ListTile(
leading: const Icon(Icons.info, color: Colors.blue),
title: const Text('Status Screen'),
subtitle: const Text('Event logs will be displayed here'),
),
),
const SizedBox(height: 16),
_buildPlaceholderEvent('LOCATION', '37.7749', '-122.4194'),
_buildPlaceholderEvent('SYNC', 'Location sent to server', ''),
_buildPlaceholderEvent('HEARTBEAT', 'Heartbeat fired', ''),
],
appBar: AppBar(
title: Text('Event Log'),
actions: [IconButton(icon: Icon(Icons.refresh), onPressed: _loadLogs)],
),
body: Container(
color: Colors.black,
child: _logs.isEmpty
? Center(
child: Text(
'No logs yet. Start tracking to see logs.',
style: TextStyle(color: Colors.grey),
),
)
: ListView.builder(
controller: _scrollController,
padding: EdgeInsets.all(8),
itemCount: _logs.length,
itemBuilder: (context, index) {
final log = _logs[index];
return _buildTerminalLine(log);
},
),
),
);
}
Widget _buildPlaceholderEvent(String type, String detail, String extra) {
return Card(
child: ListTile(
leading: Icon(_getIconForType(type), color: _getColorForType(type)),
title: Text(type),
subtitle: Text('$detail $extra'.trim()),
),
);
}
IconData _getIconForType(String type) {
switch (type) {
case 'LOCATION':
return Icons.location_on;
case 'SYNC':
return Icons.cloud_done;
case 'HEARTBEAT':
return Icons.favorite;
case 'FILTERED':
return Icons.filter_alt;
case 'ERROR':
return Icons.error;
default:
return Icons.info;
}
}
Color _getColorForType(String type) {
switch (type) {
case 'LOCATION':
return Colors.green;
case 'SYNC':
return Colors.blue;
case 'HEARTBEAT':
return Colors.orange;
case 'FILTERED':
return Colors.grey;
case 'ERROR':
return Colors.red;
default:
return Colors.grey;
}
}
}