mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Replace hardcoded English strings in 32 Dart source files with l10n references. Covers settings labels, UI actions, map/SAR features, device config, notifications, profiles, spectrum scan, and status messages for sl, de, hr, pl, es, fr, it, el, ru, tr, uk, zh, pt.
995 lines
32 KiB
Dart
995 lines
32 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../models/contact.dart';
|
|
import '../providers/connection_provider.dart';
|
|
import '../providers/contacts_provider.dart';
|
|
import '../providers/sensors_provider.dart';
|
|
import '../widgets/sensors/sensor_telemetry_card.dart';
|
|
import '../l10n/app_localizations.dart';
|
|
|
|
class SensorsTab extends StatefulWidget {
|
|
final bool isActive;
|
|
|
|
const SensorsTab({super.key, this.isActive = true});
|
|
|
|
@override
|
|
State<SensorsTab> createState() => _SensorsTabState();
|
|
}
|
|
|
|
class _SensorsTabState extends State<SensorsTab> {
|
|
Timer? _minuteTicker;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
if (widget.isActive) {
|
|
unawaited(_handleMinuteTick());
|
|
_scheduleMinuteTicker();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_minuteTicker?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant SensorsTab oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.isActive == widget.isActive) {
|
|
return;
|
|
}
|
|
|
|
if (widget.isActive) {
|
|
unawaited(_handleMinuteTick());
|
|
_scheduleMinuteTicker();
|
|
return;
|
|
}
|
|
|
|
_minuteTicker?.cancel();
|
|
_minuteTicker = null;
|
|
}
|
|
|
|
void _scheduleMinuteTicker() {
|
|
_minuteTicker?.cancel();
|
|
if (!widget.isActive) {
|
|
return;
|
|
}
|
|
|
|
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;
|
|
unawaited(_handleMinuteTick());
|
|
_minuteTicker = Timer.periodic(const Duration(minutes: 1), (_) {
|
|
unawaited(_handleMinuteTick());
|
|
});
|
|
});
|
|
}
|
|
|
|
Future<void> _handleMinuteTick() async {
|
|
if (!mounted || !widget.isActive) {
|
|
return;
|
|
}
|
|
|
|
final sensorsProvider = context.read<SensorsProvider>();
|
|
sensorsProvider.clearExpiredRefreshStates();
|
|
await sensorsProvider.refreshDueSensors(
|
|
contactsProvider: context.read<ContactsProvider>(),
|
|
connectionProvider: context.read<ConnectionProvider>(),
|
|
now: DateTime.now(),
|
|
);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
setState(() {});
|
|
}
|
|
|
|
Future<void> _showAddSensorSheet(BuildContext context) async {
|
|
final sensorsProvider = context.read<SensorsProvider>();
|
|
final contactsProvider = context.read<ContactsProvider>();
|
|
final candidates = sensorsProvider.availableCandidates(contactsProvider);
|
|
|
|
await showModalBottomSheet<void>(
|
|
context: context,
|
|
showDragHandle: true,
|
|
builder: (sheetContext) {
|
|
if (candidates.isEmpty) {
|
|
return const SafeArea(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(24),
|
|
child: Text(
|
|
'No eligible nodes available. Discover a relay or node first.',
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return SafeArea(
|
|
child: ListView(
|
|
shrinkWrap: true,
|
|
padding: const EdgeInsets.only(bottom: 20),
|
|
children: [
|
|
ListTile(
|
|
title: const Text(
|
|
'Add sensor node',
|
|
style: TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
subtitle: Text(AppLocalizations.of(context)!.pickARelayOrNodeToWatchInSensors),
|
|
),
|
|
...candidates.map(
|
|
(contact) => ListTile(
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 20,
|
|
vertical: 4,
|
|
),
|
|
leading: CircleAvatar(
|
|
radius: 24,
|
|
backgroundColor: const Color(0xFFDDEAF8),
|
|
child: Icon(
|
|
_typeIcon(contact),
|
|
color: const Color(0xFF1E4F7A),
|
|
),
|
|
),
|
|
title: Text(contact.displayName),
|
|
subtitle: _SensorCandidatePreview(contact: contact),
|
|
isThreeLine: true,
|
|
onTap: () async {
|
|
await sensorsProvider.addSensor(contact);
|
|
if (!sheetContext.mounted) return;
|
|
Navigator.of(sheetContext).pop();
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
'${contact.displayName} added to Sensors',
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _showMetricSelector(
|
|
BuildContext context,
|
|
String publicKeyHex,
|
|
Contact? contact,
|
|
) async {
|
|
await Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (pageContext) => _SensorCustomizeView(
|
|
publicKeyHex: publicKeyHex,
|
|
initialContact: contact,
|
|
onRenameMetric:
|
|
({
|
|
required BuildContext context,
|
|
required String publicKeyHex,
|
|
required SensorMetricOption option,
|
|
required SensorsProvider sensorsProvider,
|
|
}) {
|
|
return _showMetricRenameDialog(
|
|
context,
|
|
publicKeyHex: publicKeyHex,
|
|
option: option,
|
|
sensorsProvider: sensorsProvider,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _showMetricRenameDialog(
|
|
BuildContext context, {
|
|
required String publicKeyHex,
|
|
required SensorMetricOption option,
|
|
required SensorsProvider sensorsProvider,
|
|
}) async {
|
|
final controller = TextEditingController(
|
|
text:
|
|
sensorsProvider.labelOverrideFor(publicKeyHex, option.key) ??
|
|
option.defaultLabel,
|
|
);
|
|
final didSave = await showDialog<bool>(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: Text(AppLocalizations.of(context)!.renameValue),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Set a custom label for ${option.label}.',
|
|
style: Theme.of(dialogContext).textTheme.bodyMedium,
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: controller,
|
|
autofocus: true,
|
|
decoration: InputDecoration(
|
|
labelText: 'Label',
|
|
hintText: option.defaultLabel,
|
|
),
|
|
textInputAction: TextInputAction.done,
|
|
onSubmitted: (_) => Navigator.of(dialogContext).pop(true),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
if (sensorsProvider.labelOverrideFor(publicKeyHex, option.key) !=
|
|
null)
|
|
TextButton(onPressed: controller.clear, child: const Text('Reset')),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
|
child: const Text('Save'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (didSave != true) {
|
|
controller.dispose();
|
|
return;
|
|
}
|
|
|
|
final nextLabel = controller.text.trim();
|
|
await sensorsProvider.setMetricLabel(
|
|
publicKeyHex,
|
|
option.key,
|
|
nextLabel == option.defaultLabel ? null : nextLabel,
|
|
);
|
|
controller.dispose();
|
|
}
|
|
|
|
Future<void> _refreshAll(BuildContext context) async {
|
|
await context.read<SensorsProvider>().refreshAll(
|
|
contactsProvider: context.read<ContactsProvider>(),
|
|
connectionProvider: context.read<ConnectionProvider>(),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
floatingActionButton: FloatingActionButton(
|
|
onPressed: () => _showAddSensorSheet(context),
|
|
child: const Icon(Icons.add),
|
|
),
|
|
body: Consumer2<SensorsProvider, ContactsProvider>(
|
|
builder: (context, sensorsProvider, contactsProvider, child) {
|
|
final watchedKeys = sensorsProvider.watchedSensorKeys;
|
|
|
|
return RefreshIndicator(
|
|
onRefresh: () => _refreshAll(context),
|
|
child: ListView(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
|
|
children: [
|
|
if (watchedKeys.isEmpty)
|
|
const _EmptySensorsState()
|
|
else
|
|
...watchedKeys.map((key) {
|
|
Contact? contact;
|
|
for (final entry in contactsProvider.contacts) {
|
|
if (entry.publicKeyHex == key) {
|
|
contact = entry;
|
|
break;
|
|
}
|
|
}
|
|
final availableFieldKeys = sensorMetricKeysFor(contact);
|
|
final visibleFields = sensorsProvider
|
|
.effectiveVisibleFieldsFor(key, availableFieldKeys);
|
|
return SensorTelemetryCard(
|
|
contact: contact,
|
|
state: sensorsProvider.stateFor(key),
|
|
visibleFields: visibleFields,
|
|
fieldOrder: sensorsProvider.metricOrderFor(
|
|
key,
|
|
availableFieldKeys,
|
|
),
|
|
labelOverrides: sensorsProvider.labelOverridesFor(key),
|
|
fieldSpans: {
|
|
for (final field in visibleFields)
|
|
field: sensorsProvider.fieldSpanFor(key, field),
|
|
},
|
|
onRemove: () async {
|
|
await sensorsProvider.removeSensor(key);
|
|
},
|
|
onCustomize: () =>
|
|
_showMetricSelector(context, key, contact),
|
|
onRefresh: () => sensorsProvider.refreshSensor(
|
|
publicKeyHex: key,
|
|
contactsProvider: contactsProvider,
|
|
connectionProvider: context.read<ConnectionProvider>(),
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SensorCustomizeView extends StatelessWidget {
|
|
final String publicKeyHex;
|
|
final Contact? initialContact;
|
|
final Future<void> Function({
|
|
required BuildContext context,
|
|
required String publicKeyHex,
|
|
required SensorMetricOption option,
|
|
required SensorsProvider sensorsProvider,
|
|
})
|
|
onRenameMetric;
|
|
|
|
const _SensorCustomizeView({
|
|
required this.publicKeyHex,
|
|
required this.initialContact,
|
|
required this.onRenameMetric,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Consumer2<SensorsProvider, ContactsProvider>(
|
|
builder: (context, sensorsProvider, contactsProvider, child) {
|
|
Contact? contact = initialContact;
|
|
for (final entry in contactsProvider.contacts) {
|
|
if (entry.publicKeyHex == publicKeyHex) {
|
|
contact = entry;
|
|
break;
|
|
}
|
|
}
|
|
|
|
final options = sensorMetricOptionsFor(
|
|
contact,
|
|
labelOverrides: sensorsProvider.labelOverridesFor(publicKeyHex),
|
|
);
|
|
final visibleFields = sensorsProvider.effectiveVisibleFieldsFor(
|
|
publicKeyHex,
|
|
options.map((option) => option.key),
|
|
);
|
|
final autoRefreshMinutes = sensorsProvider.autoRefreshMinutesFor(
|
|
publicKeyHex,
|
|
);
|
|
final orderedFieldKeys = sensorsProvider.metricOrderFor(
|
|
publicKeyHex,
|
|
options.map((option) => option.key),
|
|
);
|
|
final optionByKey = <String, SensorMetricOption>{
|
|
for (final option in options) option.key: option,
|
|
};
|
|
final orderedOptions = orderedFieldKeys
|
|
.map((fieldKey) => optionByKey[fieldKey])
|
|
.whereType<SensorMetricOption>()
|
|
.toList(growable: false);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text('Customize ${contact?.displayName ?? 'Sensor'}'),
|
|
),
|
|
body: ListView(
|
|
padding: EdgeInsets.fromLTRB(16, 16, 16, 24),
|
|
children: [
|
|
_SensorCustomizeSectionCard(
|
|
title: AppLocalizations.of(context)!.livePreview,
|
|
subtitle:
|
|
'Changes apply immediately. This card matches the current dashboard layout for this sensor.',
|
|
child: SensorTelemetryCard(
|
|
contact: contact,
|
|
state: sensorsProvider.stateFor(publicKeyHex),
|
|
visibleFields: visibleFields,
|
|
fieldOrder: sensorsProvider.metricOrderFor(
|
|
publicKeyHex,
|
|
visibleFields,
|
|
),
|
|
labelOverrides: sensorsProvider.labelOverridesFor(
|
|
publicKeyHex,
|
|
),
|
|
fieldSpans: {
|
|
for (final field in visibleFields)
|
|
field: sensorsProvider.fieldSpanFor(publicKeyHex, field),
|
|
},
|
|
margin: EdgeInsets.zero,
|
|
emptyMetricsMessage: 'No telemetry fields available yet.',
|
|
),
|
|
),
|
|
_SensorCustomizeSectionCard(
|
|
title: AppLocalizations.of(context)!.refreshSchedule,
|
|
subtitle:
|
|
'Choose how often this sensor should refresh while your device stays connected.',
|
|
child: SensorAutoRefreshOptions(
|
|
selectedMinutes: autoRefreshMinutes,
|
|
onSelected: (minutes) {
|
|
sensorsProvider.setAutoRefreshMinutes(
|
|
publicKeyHex,
|
|
minutes,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(4, 8, 4, 12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Field layout',
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
'Use the same value-card previews shown on the dashboard to control visibility, labels, width, and order.',
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
...orderedOptions.asMap().entries.map((entry) {
|
|
final index = entry.key;
|
|
final option = entry.value;
|
|
final visible = visibleFields.contains(option.key);
|
|
final span = sensorsProvider.fieldSpanFor(
|
|
publicKeyHex,
|
|
option.key,
|
|
);
|
|
return SensorMetricSelectorItem(
|
|
option: option,
|
|
visible: visible,
|
|
span: span,
|
|
canMoveUp: index > 0,
|
|
canMoveDown: index < orderedOptions.length - 1,
|
|
onToggle: (value) {
|
|
sensorsProvider.toggleMetric(
|
|
publicKeyHex,
|
|
option.key,
|
|
value,
|
|
);
|
|
},
|
|
onRename: () => onRenameMetric(
|
|
context: context,
|
|
publicKeyHex: publicKeyHex,
|
|
option: option,
|
|
sensorsProvider: sensorsProvider,
|
|
),
|
|
onMoveUp: index > 0
|
|
? () => sensorsProvider.moveMetric(
|
|
publicKeyHex,
|
|
availableFieldKeys: orderedFieldKeys,
|
|
oldIndex: index,
|
|
newIndex: index - 1,
|
|
)
|
|
: null,
|
|
onMoveDown: index < orderedOptions.length - 1
|
|
? () => sensorsProvider.moveMetric(
|
|
publicKeyHex,
|
|
availableFieldKeys: orderedFieldKeys,
|
|
oldIndex: index,
|
|
newIndex: index + 1,
|
|
)
|
|
: null,
|
|
onSpanChanged: (selection) {
|
|
sensorsProvider.setFieldSpan(
|
|
publicKeyHex,
|
|
option.key,
|
|
selection,
|
|
);
|
|
},
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SensorCustomizeSectionCard extends StatelessWidget {
|
|
final String title;
|
|
final String subtitle;
|
|
final Widget child;
|
|
|
|
const _SensorCustomizeSectionCard({
|
|
required this.title,
|
|
required this.subtitle,
|
|
required this.child,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(28),
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [
|
|
colorScheme.surfaceContainerLow,
|
|
colorScheme.surfaceContainerHighest.withValues(alpha: 0.9),
|
|
],
|
|
),
|
|
border: Border.all(
|
|
color: colorScheme.outlineVariant.withValues(alpha: 0.35),
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.045),
|
|
blurRadius: 12,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
subtitle,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
child,
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class SensorAutoRefreshOptions extends StatelessWidget {
|
|
final int selectedMinutes;
|
|
final ValueChanged<int> onSelected;
|
|
|
|
const SensorAutoRefreshOptions({
|
|
super.key,
|
|
required this.selectedMinutes,
|
|
required this.onSelected,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: SensorsProvider.supportedAutoRefreshIntervals
|
|
.map(
|
|
(minutes) => ChoiceChip(
|
|
label: Text(_formatAutoRefreshIntervalLabel(minutes)),
|
|
selected: selectedMinutes == minutes,
|
|
onSelected: (_) => onSelected(minutes),
|
|
),
|
|
)
|
|
.toList(growable: false),
|
|
);
|
|
}
|
|
}
|
|
|
|
String _formatAutoRefreshIntervalLabel(int minutes) {
|
|
if (minutes <= 0) {
|
|
return 'Off';
|
|
}
|
|
if (minutes >= 360 && minutes % 60 == 0) {
|
|
return '${minutes ~/ 60}h';
|
|
}
|
|
return '${minutes}m';
|
|
}
|
|
|
|
class SensorMetricSelectorItem extends StatelessWidget {
|
|
final SensorMetricOption option;
|
|
final bool visible;
|
|
final int span;
|
|
final bool canMoveUp;
|
|
final bool canMoveDown;
|
|
final ValueChanged<bool> onToggle;
|
|
final VoidCallback onRename;
|
|
final VoidCallback? onMoveUp;
|
|
final VoidCallback? onMoveDown;
|
|
final ValueChanged<int> onSpanChanged;
|
|
|
|
const SensorMetricSelectorItem({
|
|
super.key,
|
|
required this.option,
|
|
required this.visible,
|
|
required this.span,
|
|
required this.canMoveUp,
|
|
required this.canMoveDown,
|
|
required this.onToggle,
|
|
required this.onRename,
|
|
this.onMoveUp,
|
|
this.onMoveDown,
|
|
required this.onSpanChanged,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final colorScheme = theme.colorScheme;
|
|
final showChannelChip = option.channel != null && option.channel != 1;
|
|
final previewCardData =
|
|
option.previewCardData ??
|
|
SensorMetricCardData(
|
|
fieldKey: option.key,
|
|
icon: Icons.sensors,
|
|
label: option.defaultLabel,
|
|
value: option.valuePreview ?? 'No telemetry yet',
|
|
accent: colorScheme.primary,
|
|
channel: option.channel,
|
|
);
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(28),
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [
|
|
colorScheme.surfaceContainerLow,
|
|
colorScheme.surfaceContainerHighest.withValues(alpha: 0.9),
|
|
],
|
|
),
|
|
border: Border.all(
|
|
color: colorScheme.outlineVariant.withValues(alpha: 0.35),
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.045),
|
|
blurRadius: 12,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
option.defaultLabel,
|
|
style: theme.textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: [
|
|
TextButton.icon(
|
|
onPressed: onRename,
|
|
style: TextButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 8,
|
|
),
|
|
foregroundColor: colorScheme.primary,
|
|
textStyle: theme.textTheme.labelLarge?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
icon: Icon(Icons.edit_outlined, size: 18),
|
|
label: Text(AppLocalizations.of(context)!.rename),
|
|
),
|
|
if (showChannelChip)
|
|
Container(
|
|
key: ValueKey(
|
|
'sensor_selector_channel_${option.key}',
|
|
),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 8,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: previewCardData.accent.withValues(
|
|
alpha: 0.10,
|
|
),
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
child: Text(
|
|
'Channel ${option.channel}',
|
|
style: theme.textTheme.labelMedium?.copyWith(
|
|
color: previewCardData.accent,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 5,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: visible
|
|
? const Color(0xFF218B63).withValues(alpha: 0.12)
|
|
: colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
child: Text(
|
|
visible ? 'Visible' : 'Hidden',
|
|
style: theme.textTheme.labelMedium?.copyWith(
|
|
color: visible
|
|
? const Color(0xFF218B63)
|
|
: colorScheme.onSurfaceVariant,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Opacity(
|
|
opacity: visible ? 1 : 0.55,
|
|
child: SensorMetricTile(
|
|
data: previewCardData,
|
|
width: double.infinity,
|
|
keyPrefix: 'sensor_selector_metric',
|
|
allowMapPreview: false,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
'Show on sensor card',
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
Switch(value: visible, onChanged: onToggle),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Text(
|
|
'Card width',
|
|
style: theme.textTheme.labelLarge?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
SegmentedButton<int>(
|
|
segments: const [
|
|
ButtonSegment<int>(value: 1, label: Text('1x')),
|
|
ButtonSegment<int>(value: 2, label: Text('2x')),
|
|
],
|
|
selected: <int>{span},
|
|
onSelectionChanged: (selection) {
|
|
onSpanChanged(selection.first);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
Text(
|
|
'Order',
|
|
style: theme.textTheme.labelLarge?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
IconButton(
|
|
tooltip: 'Move up',
|
|
onPressed: canMoveUp ? onMoveUp : null,
|
|
icon: const Icon(Icons.arrow_upward),
|
|
),
|
|
IconButton(
|
|
tooltip: 'Move down',
|
|
onPressed: canMoveDown ? onMoveDown : null,
|
|
icon: const Icon(Icons.arrow_downward),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SensorCandidatePreview extends StatelessWidget {
|
|
final Contact contact;
|
|
|
|
const _SensorCandidatePreview({required this.contact});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final telemetry = contact.telemetry;
|
|
final previewLines = <String>[
|
|
'${contact.type.displayName} • ${contact.publicKeyShort}',
|
|
if (telemetry?.batteryPercentage != null)
|
|
'Battery ${telemetry!.batteryPercentage!.toStringAsFixed(0)}% • '
|
|
'Temp ${telemetry.temperature?.toStringAsFixed(1) ?? '--'}°C',
|
|
if (telemetry?.gpsLocation != null)
|
|
'GPS ${telemetry!.gpsLocation!.latitude.toStringAsFixed(3)}, '
|
|
'${telemetry.gpsLocation!.longitude.toStringAsFixed(3)}',
|
|
];
|
|
|
|
if (previewLines.length == 1) {
|
|
previewLines.add('No telemetry preview available yet');
|
|
}
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: previewLines
|
|
.take(3)
|
|
.map(
|
|
(line) => Text(line, maxLines: 1, overflow: TextOverflow.ellipsis),
|
|
)
|
|
.toList(),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _EmptySensorsState extends StatefulWidget {
|
|
const _EmptySensorsState();
|
|
|
|
@override
|
|
State<_EmptySensorsState> createState() => _EmptySensorsStateState();
|
|
}
|
|
|
|
class _EmptySensorsStateState extends State<_EmptySensorsState> {
|
|
bool _discoveryTriggered = false;
|
|
bool _discoveryInProgress = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Auto-trigger sensor discovery when the empty state is shown
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_autoDiscover();
|
|
});
|
|
}
|
|
|
|
Future<void> _autoDiscover() async {
|
|
if (_discoveryTriggered || !mounted) return;
|
|
final connectionProvider = context.read<ConnectionProvider>();
|
|
if (!connectionProvider.deviceInfo.isConnected) return;
|
|
|
|
_discoveryTriggered = true;
|
|
setState(() => _discoveryInProgress = true);
|
|
try {
|
|
await connectionProvider.discoverNodeType(advertType: 4);
|
|
} finally {
|
|
if (mounted) setState(() => _discoveryInProgress = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _discoverSensors() async {
|
|
if (_discoveryInProgress || !mounted) return;
|
|
final connectionProvider = context.read<ConnectionProvider>();
|
|
if (!connectionProvider.deviceInfo.isConnected) return;
|
|
|
|
setState(() => _discoveryInProgress = true);
|
|
try {
|
|
await connectionProvider.discoverNodeType(advertType: 4);
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(AppLocalizations.of(context)!.sensorDiscoverySent)),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _discoveryInProgress = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isConnected =
|
|
context.watch<ConnectionProvider>().deviceInfo.isConnected;
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 64),
|
|
child: Column(
|
|
children: [
|
|
Container(
|
|
width: 84,
|
|
height: 84,
|
|
decoration: const BoxDecoration(
|
|
color: Color(0xFFDDEAF8),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(
|
|
Icons.sensors_outlined,
|
|
size: 40,
|
|
color: Color(0xFF1E4F7A),
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
Text(
|
|
'No sensor nodes added',
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 10),
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 24),
|
|
child: Text(
|
|
'Use + to add discovered relays or nodes. Pull down to refresh telemetry after adding them.',
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
FilledButton.icon(
|
|
onPressed: isConnected && !_discoveryInProgress
|
|
? _discoverSensors
|
|
: null,
|
|
icon: _discoveryInProgress
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: Colors.white,
|
|
),
|
|
)
|
|
: const Icon(Icons.sensors_outlined),
|
|
label: Text(_discoveryInProgress
|
|
? 'Discovering...'
|
|
: 'Discover Sensors'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
IconData _typeIcon(Contact contact) {
|
|
if (contact.isSensor) {
|
|
return Icons.sensors;
|
|
}
|
|
if (contact.isRepeater) {
|
|
return Icons.router;
|
|
}
|
|
if (contact.isChat) {
|
|
return Icons.person;
|
|
}
|
|
return Icons.device_hub;
|
|
}
|