diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index e8eb266..8d47f3c 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -489,7 +489,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
- CURRENT_PROJECT_VERSION = 104;
+ CURRENT_PROJECT_VERSION = 105;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -511,7 +511,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 104;
+ CURRENT_PROJECT_VERSION = 105;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -530,7 +530,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 104;
+ CURRENT_PROJECT_VERSION = 105;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -547,7 +547,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 104;
+ CURRENT_PROJECT_VERSION = 105;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -679,7 +679,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
- CURRENT_PROJECT_VERSION = 104;
+ CURRENT_PROJECT_VERSION = 105;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -702,7 +702,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
- CURRENT_PROJECT_VERSION = 104;
+ CURRENT_PROJECT_VERSION = 105;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index baee051..a45b3dd 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -43,7 +43,7 @@
CFBundleSignature
????
CFBundleVersion
- 104
+ 105
LSRequiresIPhoneOS
ITSAppUsesNonExemptEncryption
diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml
index c88930a..451471c 100644
--- a/ios/fastlane/report.xml
+++ b/ios/fastlane/report.xml
@@ -5,22 +5,17 @@
-
+
-
+
-
-
-
-
-
-
+
diff --git a/lib/providers/sensors_provider.dart b/lib/providers/sensors_provider.dart
index a558bdf..fdc42b4 100644
--- a/lib/providers/sensors_provider.dart
+++ b/lib/providers/sensors_provider.dart
@@ -11,6 +11,7 @@ import 'contacts_provider.dart';
enum SensorRefreshState { idle, refreshing, success, timeout, unavailable }
class SensorsProvider with ChangeNotifier {
+ static const Duration _successStateRetention = Duration(minutes: 1);
static const String _watchedSensorsKey = 'watched_sensor_keys';
static const String _visibleSensorMetricsKey = 'visible_sensor_metrics';
static const String _fieldSpanKey = 'sensor_field_spans';
@@ -26,6 +27,7 @@ class SensorsProvider with ChangeNotifier {
final List _watchedSensorKeys = [];
final Map _refreshStates =
{};
+ final Map _refreshStateUpdatedAt = {};
final Map> _visibleFieldsBySensor =
>{};
final Map> _fieldSpansBySensor =
@@ -189,6 +191,7 @@ class SensorsProvider with ChangeNotifier {
Future removeSensor(String publicKeyHex) async {
_watchedSensorKeys.remove(publicKeyHex);
_refreshStates.remove(publicKeyHex);
+ _refreshStateUpdatedAt.remove(publicKeyHex);
_visibleFieldsBySensor.remove(publicKeyHex);
_fieldSpansBySensor.remove(publicKeyHex);
await _persistWatchedSensors();
@@ -246,22 +249,52 @@ class SensorsProvider with ChangeNotifier {
}
if (contact == null) {
- _refreshStates[publicKeyHex] = SensorRefreshState.unavailable;
- notifyListeners();
+ _setRefreshState(publicKeyHex, SensorRefreshState.unavailable);
return;
}
- _refreshStates[publicKeyHex] = SensorRefreshState.refreshing;
- notifyListeners();
+ _setRefreshState(publicKeyHex, SensorRefreshState.refreshing);
final result = await connectionProvider.smartPing(
contactPublicKey: contact.publicKey,
hasPath: contact.hasPath,
);
- _refreshStates[publicKeyHex] = result.success
- ? SensorRefreshState.success
- : SensorRefreshState.timeout;
+ _setRefreshState(
+ publicKeyHex,
+ result.success ? SensorRefreshState.success : SensorRefreshState.timeout,
+ );
+ }
+
+ void clearExpiredRefreshStates({DateTime? now}) {
+ final cutoff = (now ?? DateTime.now()).subtract(_successStateRetention);
+ final keysToClear = [];
+
+ for (final entry in _refreshStates.entries) {
+ if (entry.value != SensorRefreshState.success) {
+ continue;
+ }
+
+ final updatedAt = _refreshStateUpdatedAt[entry.key];
+ if (updatedAt == null || !updatedAt.isAfter(cutoff)) {
+ keysToClear.add(entry.key);
+ }
+ }
+
+ if (keysToClear.isEmpty) {
+ return;
+ }
+
+ for (final key in keysToClear) {
+ _refreshStates.remove(key);
+ _refreshStateUpdatedAt.remove(key);
+ }
+ notifyListeners();
+ }
+
+ void _setRefreshState(String publicKeyHex, SensorRefreshState state) {
+ _refreshStates[publicKeyHex] = state;
+ _refreshStateUpdatedAt[publicKeyHex] = DateTime.now();
notifyListeners();
}
}
diff --git a/lib/screens/device_config_screen.dart b/lib/screens/device_config_screen.dart
index c072cfb..1a13b84 100644
--- a/lib/screens/device_config_screen.dart
+++ b/lib/screens/device_config_screen.dart
@@ -398,26 +398,51 @@ class _DeviceConfigScreenState extends State {
Widget build(BuildContext context) {
final deviceInfo = context.watch().deviceInfo;
final theme = Theme.of(context);
+ final colorScheme = theme.colorScheme;
+ final locationSet =
+ (deviceInfo.advLat != null && deviceInfo.advLat != 0) ||
+ (deviceInfo.advLon != null && deviceInfo.advLon != 0);
return Scaffold(
appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)),
- body: ListView(
- padding: const EdgeInsets.all(16),
- children: [
- // Device Info Card
- Card(
- child: Padding(
- padding: const EdgeInsets.all(16),
+ body: SafeArea(
+ child: ListView(
+ padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
+ children: [
+ _ConfigHeroCard(
+ title: deviceInfo.selfName ?? deviceInfo.deviceName ?? 'MeshCore',
+ subtitle:
+ '${_getDeviceTypeString(context, deviceInfo.deviceType)} • ${deviceInfo.semanticVersion ?? deviceInfo.manufacturerModel ?? AppLocalizations.of(context)!.unknown}',
+ chips: [
+ _StatusChipData(
+ icon: Icons.bluetooth,
+ label:
+ '${AppLocalizations.of(context)!.bleName}: ${deviceInfo.deviceName ?? AppLocalizations.of(context)!.unknown}',
+ ),
+ _StatusChipData(
+ icon: Icons.my_location,
+ label: locationSet ? 'Location ready' : 'Location off',
+ emphasized: locationSet,
+ ),
+ _StatusChipData(
+ icon: Icons.settings_input_antenna,
+ label: '${_freqController.text} MHz • $_selectedBandwidth',
+ ),
+ _StatusChipData(
+ icon: Icons.key,
+ label: 'FW v${deviceInfo.firmwareVersion?.toString() ?? "?"}',
+ ),
+ ],
+ ),
+ const SizedBox(height: 20),
+ _ConfigSectionCard(
+ title: AppLocalizations.of(context)!.deviceInformation,
+ subtitle:
+ '${deviceInfo.manufacturerModel ?? AppLocalizations.of(context)!.unknown} • ${deviceInfo.firmwareBuildDate ?? AppLocalizations.of(context)!.unknown}',
+ icon: Icons.memory_rounded,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- Text(
- AppLocalizations.of(context)!.deviceInformation,
- style: theme.textTheme.titleLarge?.copyWith(
- fontWeight: FontWeight.bold,
- ),
- ),
- const SizedBox(height: 16),
_InfoRow(
AppLocalizations.of(context)!.bleName,
deviceInfo.deviceName ??
@@ -467,42 +492,75 @@ class _DeviceConfigScreenState extends State {
],
),
),
- ),
-
- const SizedBox(height: 24),
-
- // Public Info Section
- Card(
- child: Padding(
- padding: const EdgeInsets.all(16),
+ const SizedBox(height: 20),
+ _ConfigSectionCard(
+ title: AppLocalizations.of(context)!.publicInfo,
+ subtitle: AppLocalizations.of(context)!.nameBroadcastInMesh,
+ icon: Icons.public_rounded,
+ trailing: FilledButton.icon(
+ onPressed: _savePublicInfo,
+ icon: const Icon(Icons.save_outlined),
+ label: Text(AppLocalizations.of(context)!.save),
+ ),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(
- AppLocalizations.of(context)!.publicInfo,
- style: theme.textTheme.titleLarge?.copyWith(
- fontWeight: FontWeight.bold,
+ Container(
+ padding: const EdgeInsets.all(14),
+ decoration: BoxDecoration(
+ color: colorScheme.surfaceContainerHighest.withValues(
+ alpha: 0.45,
+ ),
+ borderRadius: BorderRadius.circular(18),
+ ),
+ child: Row(
+ children: [
+ Icon(
+ _telemetryEnabled
+ ? Icons.travel_explore
+ : Icons.location_disabled,
+ color: _telemetryEnabled
+ ? colorScheme.primary
+ : colorScheme.onSurfaceVariant,
),
- ),
- Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- const SizedBox(width: 8),
- IconButton.filled(
- onPressed: _savePublicInfo,
- icon: const Icon(Icons.save),
- tooltip: AppLocalizations.of(context)!.save,
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ AppLocalizations.of(
+ context,
+ )!.telemetryAndLocationSharing,
+ style: theme.textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ const SizedBox(height: 2),
+ Text(
+ _telemetryEnabled
+ ? 'This device advertises position data to the mesh.'
+ : 'Position broadcasting is currently disabled.',
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
),
- ],
- ),
- ],
+ ),
+ const SizedBox(width: 12),
+ Switch(
+ value: _telemetryEnabled,
+ onChanged: (value) {
+ setState(() {
+ _telemetryEnabled = value;
+ });
+ },
+ ),
+ ],
+ ),
),
- const SizedBox(height: 16),
-
- // Mesh Network Name
+ const SizedBox(height: 18),
TextField(
controller: _nameController,
decoration: InputDecoration(
@@ -513,62 +571,21 @@ class _DeviceConfigScreenState extends State {
)!.nameBroadcastInMesh,
),
),
- const SizedBox(height: 8),
-
- // Telemetry Toggle - Compact version
- Row(
- children: [
- Expanded(
- child: Text(
- AppLocalizations.of(
- context,
- )!.telemetryAndLocationSharing,
- style: theme.textTheme.bodyMedium,
- ),
- ),
- Switch(
- value: _telemetryEnabled,
- onChanged: (value) {
- setState(() {
- _telemetryEnabled = value;
- });
- },
- ),
- ],
- ),
-
- // GPS Coordinates (only show if telemetry enabled)
if (_telemetryEnabled) ...[
- const SizedBox(height: 12),
+ const SizedBox(height: 16),
Row(
children: [
Expanded(
- child: TextField(
+ child: _CompactCoordinateField(
controller: _latController,
- decoration: InputDecoration(
- labelText: AppLocalizations.of(context)!.lat,
- border: const OutlineInputBorder(),
- isDense: true,
- ),
- keyboardType: const TextInputType.numberWithOptions(
- decimal: true,
- signed: true,
- ),
+ label: AppLocalizations.of(context)!.lat,
),
),
const SizedBox(width: 8),
Expanded(
- child: TextField(
+ child: _CompactCoordinateField(
controller: _lonController,
- decoration: InputDecoration(
- labelText: AppLocalizations.of(context)!.lon,
- border: const OutlineInputBorder(),
- isDense: true,
- ),
- keyboardType: const TextInputType.numberWithOptions(
- decimal: true,
- signed: true,
- ),
+ label: AppLocalizations.of(context)!.lon,
),
),
const SizedBox(width: 8),
@@ -585,36 +602,45 @@ class _DeviceConfigScreenState extends State {
],
),
),
- ),
-
- const SizedBox(height: 24),
-
- // Radio Settings Section
- Card(
- child: Padding(
- padding: const EdgeInsets.all(16),
+ const SizedBox(height: 20),
+ _ConfigSectionCard(
+ title: AppLocalizations.of(context)!.radioSettings,
+ subtitle:
+ '${_freqController.text} MHz • SF$_selectedSpreadingFactor • CR$_selectedCodingRate',
+ icon: Icons.settings_input_antenna_rounded,
+ trailing: FilledButton.icon(
+ onPressed: _saveRadioSettings,
+ icon: const Icon(Icons.save_outlined),
+ label: Text(AppLocalizations.of(context)!.save),
+ ),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
- Text(
- AppLocalizations.of(context)!.radioSettings,
- style: theme.textTheme.titleLarge?.copyWith(
- fontWeight: FontWeight.bold,
+ Expanded(
+ child: _RadioMetricTile(
+ label: AppLocalizations.of(context)!.bandwidth,
+ value: _selectedBandwidth,
),
),
- IconButton.filled(
- onPressed: _saveRadioSettings,
- icon: const Icon(Icons.save),
- tooltip: AppLocalizations.of(context)!.save,
+ const SizedBox(width: 10),
+ Expanded(
+ child: _RadioMetricTile(
+ label: AppLocalizations.of(context)!.spreadingFactor,
+ value: 'SF$_selectedSpreadingFactor',
+ ),
+ ),
+ const SizedBox(width: 10),
+ Expanded(
+ child: _RadioMetricTile(
+ label: AppLocalizations.of(context)!.codingRate,
+ value: 'CR$_selectedCodingRate',
+ ),
),
],
),
const SizedBox(height: 16),
-
- // LoRa Frequency
TextField(
controller: _freqController,
decoration: InputDecoration(
@@ -629,8 +655,6 @@ class _DeviceConfigScreenState extends State {
),
),
const SizedBox(height: 16),
-
- // Bandwidth
DropdownButtonFormField(
initialValue: _selectedBandwidth,
decoration: InputDecoration(
@@ -652,8 +676,6 @@ class _DeviceConfigScreenState extends State {
},
),
const SizedBox(height: 16),
-
- // Spreading Factor
DropdownButtonFormField(
initialValue: _selectedSpreadingFactor,
decoration: InputDecoration(
@@ -677,8 +699,6 @@ class _DeviceConfigScreenState extends State {
},
),
const SizedBox(height: 16),
-
- // Coding Rate
DropdownButtonFormField(
initialValue: _selectedCodingRate,
decoration: InputDecoration(
@@ -702,8 +722,6 @@ class _DeviceConfigScreenState extends State {
},
),
const SizedBox(height: 16),
-
- // TX Power
TextField(
controller: _txPowerController,
decoration: InputDecoration(
@@ -715,37 +733,42 @@ class _DeviceConfigScreenState extends State {
),
keyboardType: TextInputType.number,
),
-
- // Repeat Mode (firmware v9+)
if (deviceInfo.clientRepeat != null) ...[
- const SizedBox(height: 8),
- SwitchListTile(
- contentPadding: EdgeInsets.zero,
- title: const Text('Client Repeat Mode'),
- subtitle:
- deviceInfo.allowedRepeatFreqRanges != null &&
- deviceInfo.allowedRepeatFreqRanges!.isNotEmpty
- ? Text(
- 'Allowed: ${deviceInfo.allowedRepeatFreqRanges!.map((r) => r.lower == r.upper ? '${(r.lower / 1000).toStringAsFixed(3)} MHz' : '${(r.lower / 1000).toStringAsFixed(3)}–${(r.upper / 1000).toStringAsFixed(3)} MHz').join(', ')}',
- )
- : const Text(
- 'Repeat packets on behalf of nearby nodes',
- ),
- value: _repeatEnabled,
- onChanged: (value) {
- setState(() {
- _repeatEnabled = value;
- });
- },
+ const SizedBox(height: 16),
+ Container(
+ padding: const EdgeInsets.all(14),
+ decoration: BoxDecoration(
+ color: colorScheme.surfaceContainerHighest.withValues(
+ alpha: 0.45,
+ ),
+ borderRadius: BorderRadius.circular(18),
+ ),
+ child: SwitchListTile(
+ contentPadding: EdgeInsets.zero,
+ title: const Text('Client Repeat Mode'),
+ subtitle:
+ deviceInfo.allowedRepeatFreqRanges != null &&
+ deviceInfo.allowedRepeatFreqRanges!.isNotEmpty
+ ? Text(
+ 'Allowed: ${deviceInfo.allowedRepeatFreqRanges!.map((r) => r.lower == r.upper ? '${(r.lower / 1000).toStringAsFixed(3)} MHz' : '${(r.lower / 1000).toStringAsFixed(3)}–${(r.upper / 1000).toStringAsFixed(3)} MHz').join(', ')}',
+ )
+ : const Text(
+ 'Repeat packets on behalf of nearby nodes',
+ ),
+ value: _repeatEnabled,
+ onChanged: (value) {
+ setState(() {
+ _repeatEnabled = value;
+ });
+ },
+ ),
),
],
],
),
),
- ),
-
- const SizedBox(height: 24),
- ],
+ ],
+ ),
),
);
}
@@ -772,6 +795,276 @@ class _DeviceConfigScreenState extends State {
}
}
+class _ConfigHeroCard extends StatelessWidget {
+ final String title;
+ final String subtitle;
+ final List<_StatusChipData> chips;
+
+ const _ConfigHeroCard({
+ required this.title,
+ required this.subtitle,
+ required this.chips,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final colorScheme = theme.colorScheme;
+
+ return Container(
+ padding: const EdgeInsets.all(20),
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ colors: [
+ colorScheme.primaryContainer,
+ colorScheme.surfaceContainerHighest,
+ ],
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ ),
+ borderRadius: BorderRadius.circular(28),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Container(
+ width: 52,
+ height: 52,
+ decoration: BoxDecoration(
+ color: colorScheme.onPrimaryContainer.withValues(alpha: 0.12),
+ borderRadius: BorderRadius.circular(18),
+ ),
+ child: Icon(
+ Icons.tune_rounded,
+ color: colorScheme.onPrimaryContainer,
+ ),
+ ),
+ const SizedBox(width: 14),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ title,
+ style: theme.textTheme.headlineSmall?.copyWith(
+ fontWeight: FontWeight.w800,
+ color: colorScheme.onPrimaryContainer,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ subtitle,
+ style: theme.textTheme.bodyMedium?.copyWith(
+ color: colorScheme.onPrimaryContainer.withValues(
+ alpha: 0.82,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 18),
+ Wrap(
+ spacing: 10,
+ runSpacing: 10,
+ children: chips.map(_StatusChip.new).toList(),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _ConfigSectionCard extends StatelessWidget {
+ final String title;
+ final String subtitle;
+ final IconData icon;
+ final Widget child;
+ final Widget? trailing;
+
+ const _ConfigSectionCard({
+ required this.title,
+ required this.subtitle,
+ required this.icon,
+ required this.child,
+ this.trailing,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final colorScheme = theme.colorScheme;
+
+ return Card(
+ clipBehavior: Clip.antiAlias,
+ child: Padding(
+ padding: const EdgeInsets.all(18),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Container(
+ width: 42,
+ height: 42,
+ decoration: BoxDecoration(
+ color: colorScheme.primary.withValues(alpha: 0.10),
+ borderRadius: BorderRadius.circular(14),
+ ),
+ child: Icon(icon, color: colorScheme.primary),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ title,
+ style: theme.textTheme.titleLarge?.copyWith(
+ fontWeight: FontWeight.w800,
+ ),
+ ),
+ const SizedBox(height: 3),
+ Text(
+ subtitle,
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ ),
+ if (trailing != null) ...[const SizedBox(width: 12), trailing!],
+ ],
+ ),
+ const SizedBox(height: 18),
+ child,
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _StatusChipData {
+ final IconData icon;
+ final String label;
+ final bool emphasized;
+
+ const _StatusChipData({
+ required this.icon,
+ required this.label,
+ this.emphasized = false,
+ });
+}
+
+class _StatusChip extends StatelessWidget {
+ final _StatusChipData data;
+
+ const _StatusChip(this.data);
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final colorScheme = theme.colorScheme;
+ final chipColor = data.emphasized
+ ? colorScheme.primary.withValues(alpha: 0.14)
+ : colorScheme.onPrimaryContainer.withValues(alpha: 0.10);
+
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
+ decoration: BoxDecoration(
+ color: chipColor,
+ borderRadius: BorderRadius.circular(999),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(data.icon, size: 16, color: colorScheme.onPrimaryContainer),
+ const SizedBox(width: 8),
+ Text(
+ data.label,
+ style: theme.textTheme.labelLarge?.copyWith(
+ color: colorScheme.onPrimaryContainer,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _CompactCoordinateField extends StatelessWidget {
+ final TextEditingController controller;
+ final String label;
+
+ const _CompactCoordinateField({
+ required this.controller,
+ required this.label,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return TextField(
+ controller: controller,
+ decoration: InputDecoration(
+ labelText: label,
+ border: const OutlineInputBorder(),
+ isDense: true,
+ ),
+ keyboardType: const TextInputType.numberWithOptions(
+ decimal: true,
+ signed: true,
+ ),
+ );
+ }
+}
+
+class _RadioMetricTile extends StatelessWidget {
+ final String label;
+ final String value;
+
+ const _RadioMetricTile({required this.label, required this.value});
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final colorScheme = theme.colorScheme;
+
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
+ decoration: BoxDecoration(
+ color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.45),
+ borderRadius: BorderRadius.circular(18),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ label,
+ style: theme.textTheme.labelMedium?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ const SizedBox(height: 6),
+ Text(
+ value,
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w800,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
class _InfoRow extends StatelessWidget {
final String label;
final String value;
diff --git a/lib/screens/sensors_tab.dart b/lib/screens/sensors_tab.dart
index c674ad6..f07c252 100644
--- a/lib/screens/sensors_tab.dart
+++ b/lib/screens/sensors_tab.dart
@@ -1,3 +1,5 @@
+import 'dart:async';
+
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart';
@@ -10,9 +12,53 @@ import '../providers/contacts_provider.dart';
import '../providers/sensors_provider.dart';
import '../utils/location_formats.dart';
-class SensorsTab extends StatelessWidget {
+class SensorsTab extends StatefulWidget {
const SensorsTab({super.key});
+ @override
+ State createState() => _SensorsTabState();
+}
+
+class _SensorsTabState extends State {
+ Timer? _minuteTicker;
+
+ @override
+ void initState() {
+ super.initState();
+ _scheduleMinuteTicker();
+ }
+
+ @override
+ void dispose() {
+ _minuteTicker?.cancel();
+ super.dispose();
+ }
+
+ void _scheduleMinuteTicker() {
+ _minuteTicker?.cancel();
+
+ final now = DateTime.now();
+ final nextMinute = DateTime(
+ now.year,
+ now.month,
+ now.day,
+ now.hour,
+ now.minute + 1,
+ );
+ final delay = nextMinute.difference(now);
+
+ _minuteTicker = Timer(delay, () {
+ if (!mounted) return;
+ context.read().clearExpiredRefreshStates();
+ setState(() {});
+ _minuteTicker = Timer.periodic(const Duration(minutes: 1), (_) {
+ if (!mounted) return;
+ context.read().clearExpiredRefreshStates();
+ setState(() {});
+ });
+ });
+ }
+
Future _showAddSensorSheet(BuildContext context) async {
final sensorsProvider = context.read();
final contactsProvider = context.read();
@@ -588,7 +634,7 @@ class _SensorCard extends StatelessWidget {
String _formatTelemetryTime(DateTime timestamp) {
final diff = DateTime.now().difference(timestamp);
- if (diff.inMinutes < 1) return 'just now';
+ if (diff.inMinutes < 1) return 'now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
return '${diff.inDays}d ago';
@@ -688,49 +734,33 @@ class _MetricTile extends StatelessWidget {
final location = data.mapLocation;
if (location == null) return;
- await showDialog(
- context: context,
- builder: (dialogContext) {
- return Dialog(
- insetPadding: const EdgeInsets.all(16),
- clipBehavior: Clip.antiAlias,
- child: SizedBox(
- height: 420,
- child: Column(
+ await Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (pageContext) {
+ return Scaffold(
+ appBar: AppBar(
+ title: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(data.label),
+ Text(
+ data.value,
+ style: Theme.of(pageContext).textTheme.bodySmall,
+ ),
+ ],
+ ),
+ ),
+ body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- Padding(
- padding: const EdgeInsets.fromLTRB(16, 14, 8, 8),
- child: Row(
- children: [
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- data.label,
- style: Theme.of(context).textTheme.titleMedium,
- ),
- const SizedBox(height: 4),
- Text(
- data.value,
- style: Theme.of(context).textTheme.bodyMedium,
- ),
- if (data.secondaryValue != null)
- Text(
- data.secondaryValue!,
- style: Theme.of(context).textTheme.bodySmall,
- ),
- ],
- ),
- ),
- IconButton(
- onPressed: () => Navigator.of(dialogContext).pop(),
- icon: const Icon(Icons.close),
- ),
- ],
+ if (data.secondaryValue != null)
+ Padding(
+ padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
+ child: Text(
+ data.secondaryValue!,
+ style: Theme.of(pageContext).textTheme.bodyMedium,
+ ),
),
- ),
Expanded(
child: flutter_map.FlutterMap(
options: flutter_map.MapOptions(
@@ -763,9 +793,10 @@ class _MetricTile extends StatelessWidget {
),
],
),
- ),
- );
- },
+ );
+ },
+ fullscreenDialog: true,
+ ),
);
}
diff --git a/pubspec.lock b/pubspec.lock
index 08b4afd..e9a438d 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -969,7 +969,7 @@ packages:
source: hosted
version: "2.2.22"
path_provider_foundation:
- dependency: "direct overridden"
+ dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
@@ -1582,5 +1582,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
- dart: ">=3.10.0 <4.0.0"
- flutter: ">=3.38.1"
+ dart: ">=3.10.3 <4.0.0"
+ flutter: ">=3.38.4"
diff --git a/pubspec.yaml b/pubspec.yaml
index e0ed892..1cc0deb 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -138,9 +138,6 @@ dev_dependencies:
flutter_launcher_icons: "^0.14.4"
fake_async: ^1.3.3
-dependency_overrides:
- path_provider_foundation: 2.6.0
-
flutter_launcher_icons:
android: "launcher_icon"
ios: true