mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
feat: save all pending work
ref:
This commit is contained in:
129
lib/widgets/common/contact_avatar.dart
Normal file
129
lib/widgets/common/contact_avatar.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/contact.dart';
|
||||
import '../../services/trail_color_service.dart';
|
||||
import '../../utils/avatar_label_helper.dart';
|
||||
|
||||
class ContactAvatar extends StatelessWidget {
|
||||
final Contact contact;
|
||||
final double radius;
|
||||
final String? displayName;
|
||||
|
||||
const ContactAvatar({
|
||||
super.key,
|
||||
required this.contact,
|
||||
this.radius = 20,
|
||||
this.displayName,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backgroundColor = _getBackgroundColor(context);
|
||||
final foregroundColor = _getForegroundColor(backgroundColor);
|
||||
final emoji = contact.roleEmoji;
|
||||
|
||||
if (emoji != null && emoji.isNotEmpty) {
|
||||
return _buildAvatarFrame(
|
||||
backgroundColor: backgroundColor,
|
||||
child: Text(emoji, style: TextStyle(fontSize: radius * 1.05)),
|
||||
);
|
||||
}
|
||||
|
||||
if (_shouldUseLabelFallback) {
|
||||
return _buildAvatarFrame(
|
||||
backgroundColor: backgroundColor,
|
||||
child: Text(
|
||||
AvatarLabelHelper.buildLabel(displayName ?? contact.displayName),
|
||||
style: TextStyle(
|
||||
color: foregroundColor,
|
||||
fontSize: radius * 0.68,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.4,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _buildAvatarFrame(
|
||||
backgroundColor: backgroundColor,
|
||||
child: Icon(
|
||||
_getTypeIcon(contact.type),
|
||||
color: foregroundColor,
|
||||
size: radius,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatarFrame({
|
||||
required Color backgroundColor,
|
||||
required Widget child,
|
||||
}) {
|
||||
if (_usesSquareShape) {
|
||||
return Container(
|
||||
width: radius * 2,
|
||||
height: radius * 2,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(radius * 0.6),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
return CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundColor: backgroundColor,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
bool get _shouldUseLabelFallback =>
|
||||
contact.type == ContactType.chat ||
|
||||
contact.type == ContactType.channel ||
|
||||
contact.type == ContactType.room;
|
||||
|
||||
bool get _usesSquareShape =>
|
||||
contact.type == ContactType.channel || contact.type == ContactType.room;
|
||||
|
||||
Color _getBackgroundColor(BuildContext context) {
|
||||
if (_shouldUseLabelFallback || (contact.roleEmoji?.isNotEmpty ?? false)) {
|
||||
return TrailColorService.getTrailColor(contact);
|
||||
}
|
||||
|
||||
switch (contact.type) {
|
||||
case ContactType.none:
|
||||
return Theme.of(context).colorScheme.surfaceContainerHighest;
|
||||
case ContactType.chat:
|
||||
return Colors.blue;
|
||||
case ContactType.repeater:
|
||||
return Colors.orange;
|
||||
case ContactType.room:
|
||||
return Colors.purple;
|
||||
case ContactType.channel:
|
||||
return Colors.teal;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getForegroundColor(Color backgroundColor) {
|
||||
return ThemeData.estimateBrightnessForColor(backgroundColor) ==
|
||||
Brightness.dark
|
||||
? Colors.white
|
||||
: Colors.black87;
|
||||
}
|
||||
|
||||
IconData _getTypeIcon(ContactType type) {
|
||||
switch (type) {
|
||||
case ContactType.none:
|
||||
return Icons.help_outline;
|
||||
case ContactType.chat:
|
||||
return Icons.person;
|
||||
case ContactType.repeater:
|
||||
return Icons.router;
|
||||
case ContactType.room:
|
||||
return Icons.meeting_room;
|
||||
case ContactType.channel:
|
||||
return Icons.public;
|
||||
}
|
||||
}
|
||||
}
|
||||
394
lib/widgets/device/spectrum_scan_panel.dart
Normal file
394
lib/widgets/device/spectrum_scan_panel.dart
Normal file
@@ -0,0 +1,394 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:meshcore_client/meshcore_client.dart';
|
||||
|
||||
class SpectrumScanPanel extends StatelessWidget {
|
||||
final ThemeData theme;
|
||||
final bool scanSupported;
|
||||
final bool isRunning;
|
||||
final double rangeMinMhz;
|
||||
final double rangeMaxMhz;
|
||||
final RangeValues rangeValues;
|
||||
final double bandwidthKhz;
|
||||
final int? selectedFrequencyKhz;
|
||||
final List<SpectrumScanCandidate> graphCandidates;
|
||||
final List<SpectrumScanCandidate> selectableCandidates;
|
||||
final ValueChanged<RangeValues> onRangeChanged;
|
||||
final ValueChanged<int?> onCandidateChanged;
|
||||
final VoidCallback onRunScan;
|
||||
final VoidCallback onApplySelected;
|
||||
|
||||
const SpectrumScanPanel({
|
||||
super.key,
|
||||
required this.theme,
|
||||
required this.scanSupported,
|
||||
required this.isRunning,
|
||||
required this.rangeMinMhz,
|
||||
required this.rangeMaxMhz,
|
||||
required this.rangeValues,
|
||||
required this.bandwidthKhz,
|
||||
required this.selectedFrequencyKhz,
|
||||
required this.graphCandidates,
|
||||
required this.selectableCandidates,
|
||||
required this.onRangeChanged,
|
||||
required this.onCandidateChanged,
|
||||
required this.onRunScan,
|
||||
required this.onApplySelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest.withValues(
|
||||
alpha: 0.45,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.tune, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Power Scan',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: scanSupported && !isRunning ? onRunScan : null,
|
||||
icon: isRunning
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.radar),
|
||||
label: Text(
|
||||
scanSupported
|
||||
? (isRunning ? 'Scanning' : 'Scan')
|
||||
: 'Unavailable',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
scanSupported
|
||||
? 'Full range with bandwidth footprint. Firmware enforces hardware band limits and pauses the mesh while scanning.'
|
||||
: 'Full range with bandwidth footprint. This companion does not support spectrum scan mode, so scanning is disabled.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_FrequencyRangePreview(
|
||||
minMhz: rangeMinMhz,
|
||||
maxMhz: rangeMaxMhz,
|
||||
selectedRange: rangeValues,
|
||||
selectedBandwidthKhz: bandwidthKhz,
|
||||
selectedFrequencyKhz: selectedFrequencyKhz,
|
||||
candidates: graphCandidates,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 14,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
_LegendChip(
|
||||
color: theme.colorScheme.primary,
|
||||
label: 'Quiet',
|
||||
),
|
||||
const _LegendChip(color: Colors.orange, label: 'Moderate'),
|
||||
_LegendChip(
|
||||
color: theme.colorScheme.error,
|
||||
label: 'Busy',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'${rangeValues.start.toStringAsFixed(3)} MHz',
|
||||
style: theme.textTheme.labelMedium,
|
||||
),
|
||||
Text(
|
||||
'${rangeValues.end.toStringAsFixed(3)} MHz',
|
||||
style: theme.textTheme.labelMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
RangeSlider(
|
||||
values: rangeValues,
|
||||
min: rangeMinMhz,
|
||||
max: rangeMaxMhz,
|
||||
divisions: (((rangeMaxMhz - rangeMinMhz) * 20).round()).clamp(
|
||||
1,
|
||||
400,
|
||||
),
|
||||
labels: RangeLabels(
|
||||
rangeValues.start.toStringAsFixed(3),
|
||||
rangeValues.end.toStringAsFixed(3),
|
||||
),
|
||||
onChanged: onRangeChanged,
|
||||
),
|
||||
if (selectableCandidates.isEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
child: Text(
|
||||
scanSupported
|
||||
? 'No scan results yet. Adjust the range and run a scan to populate candidate frequencies.'
|
||||
: 'Spectrum preview only. This companion can display the configured span, but cannot scan for open channels.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
const SizedBox(height: 10),
|
||||
DropdownButtonFormField<int>(
|
||||
initialValue: selectedFrequencyKhz,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Candidate frequency',
|
||||
border: OutlineInputBorder(),
|
||||
helperText: 'Best frequencies for the current bandwidth',
|
||||
),
|
||||
items: selectableCandidates.map((candidate) {
|
||||
return DropdownMenuItem<int>(
|
||||
value: candidate.centerFrequencyKhz,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'${candidate.centerFrequencyMhz.toStringAsFixed(3)} MHz',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
'${candidate.occupancyPercent}% occupied | peak ${candidate.peakRssiDbm} dBm',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
selectedItemBuilder: (context) {
|
||||
return selectableCandidates.map((candidate) {
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'${candidate.centerFrequencyMhz.toStringAsFixed(3)} MHz',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
},
|
||||
onChanged: onCandidateChanged,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: selectedFrequencyKhz == null
|
||||
? null
|
||||
: onApplySelected,
|
||||
icon: const Icon(Icons.north_east),
|
||||
label: const Text('Use selected frequency'),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LegendChip extends StatelessWidget {
|
||||
final Color color;
|
||||
final String label;
|
||||
|
||||
const _LegendChip({required this.color, required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(label, style: Theme.of(context).textTheme.labelMedium),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FrequencyRangePreview extends StatelessWidget {
|
||||
final double minMhz;
|
||||
final double maxMhz;
|
||||
final RangeValues selectedRange;
|
||||
final double selectedBandwidthKhz;
|
||||
final int? selectedFrequencyKhz;
|
||||
final List<SpectrumScanCandidate> candidates;
|
||||
|
||||
const _FrequencyRangePreview({
|
||||
required this.minMhz,
|
||||
required this.maxMhz,
|
||||
required this.selectedRange,
|
||||
required this.selectedBandwidthKhz,
|
||||
required this.selectedFrequencyKhz,
|
||||
required this.candidates,
|
||||
});
|
||||
|
||||
double _positionFor(double mhz) {
|
||||
final span = maxMhz - minMhz;
|
||||
if (span <= 0) return 0;
|
||||
return ((mhz - minMhz) / span).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
Color _candidateColor(BuildContext context, int occupancyPercent) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
if (occupancyPercent <= 10) return scheme.primary;
|
||||
if (occupancyPercent <= 35) return Colors.orange;
|
||||
return scheme.error;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final spanMhz = maxMhz - minMhz;
|
||||
final selectedFreqMhz = selectedFrequencyKhz != null
|
||||
? selectedFrequencyKhz! / 1000.0
|
||||
: null;
|
||||
final bwMhz = selectedBandwidthKhz / 1000.0;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final rangeLeft = _positionFor(selectedRange.start) * width;
|
||||
final rangeRight = _positionFor(selectedRange.end) * width;
|
||||
|
||||
double? bwLeft;
|
||||
double? bwWidth;
|
||||
if (selectedFreqMhz != null && spanMhz > 0) {
|
||||
bwLeft = _positionFor(selectedFreqMhz - (bwMhz / 2)) * width;
|
||||
final bwRight = _positionFor(selectedFreqMhz + (bwMhz / 2)) * width;
|
||||
bwWidth = (bwRight - bwLeft).clamp(4.0, width);
|
||||
}
|
||||
|
||||
return Container(
|
||||
height: 108,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
scheme.surface,
|
||||
scheme.surfaceContainerHighest.withValues(alpha: 0.9),
|
||||
],
|
||||
),
|
||||
border: Border.all(color: scheme.outlineVariant),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 12,
|
||||
),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
scheme.primary.withValues(alpha: 0.12),
|
||||
scheme.tertiary.withValues(alpha: 0.08),
|
||||
scheme.primary.withValues(alpha: 0.12),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: rangeLeft,
|
||||
top: 12,
|
||||
width: (rangeRight - rangeLeft).clamp(8.0, width),
|
||||
height: 56,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: scheme.primary.withValues(alpha: 0.20),
|
||||
border: Border.all(color: scheme.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (bwLeft != null && bwWidth != null)
|
||||
Positioned(
|
||||
left: bwLeft,
|
||||
top: 28,
|
||||
width: bwWidth,
|
||||
height: 24,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
color: scheme.tertiary.withValues(alpha: 0.32),
|
||||
border: Border.all(color: scheme.tertiary),
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final candidate in candidates)
|
||||
Positioned(
|
||||
left: (_positionFor(candidate.centerFrequencyMhz) * width)
|
||||
.clamp(10.0, width - 18.0),
|
||||
top: 72,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: _candidateColor(
|
||||
context,
|
||||
candidate.occupancyPercent,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
candidate.centerFrequencyMhz.toStringAsFixed(3),
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user