feat: implement tag CRUD UI
This commit is contained in:
@@ -0,0 +1,849 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../data/sources/ble/tag_scanner.dart';
|
||||
import '../../providers.dart';
|
||||
|
||||
// ─── Beacon configuration (for the "configure" path) ──────────────────────
|
||||
|
||||
sealed class BeaconConfig {
|
||||
const BeaconConfig();
|
||||
|
||||
String get tagId;
|
||||
|
||||
static BeaconConfig generate(BeaconType type) {
|
||||
final rng = Random.secure();
|
||||
return switch (type) {
|
||||
BeaconType.iBeacon => () {
|
||||
final bytes = List<int>.generate(16, (_) => rng.nextInt(256));
|
||||
bytes[6] = (bytes[6] & 0x0F) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3F) | 0x80;
|
||||
return IBeaconConfig(
|
||||
uuid: _bytesToUuid(bytes),
|
||||
major: rng.nextInt(65536),
|
||||
minor: rng.nextInt(65536),
|
||||
);
|
||||
}(),
|
||||
BeaconType.eddystoneUid => EddystoneUidConfig(
|
||||
namespace: _randomHex(rng, 10),
|
||||
instance: _randomHex(rng, 6),
|
||||
),
|
||||
BeaconType.altBeacon => AltBeaconConfig(beaconId: _randomHex(rng, 20)),
|
||||
};
|
||||
}
|
||||
|
||||
static String _bytesToUuid(List<int> b) {
|
||||
final h = b.map((x) => x.toRadixString(16).padLeft(2, '0')).join();
|
||||
return '${h.substring(0, 8)}-${h.substring(8, 12)}-'
|
||||
'${h.substring(12, 16)}-${h.substring(16, 20)}-${h.substring(20)}';
|
||||
}
|
||||
|
||||
static String _randomHex(Random rng, int byteCount) => List.generate(
|
||||
byteCount,
|
||||
(_) => rng.nextInt(256),
|
||||
).map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
|
||||
class IBeaconConfig extends BeaconConfig {
|
||||
const IBeaconConfig({
|
||||
required this.uuid,
|
||||
required this.major,
|
||||
required this.minor,
|
||||
});
|
||||
|
||||
final String uuid;
|
||||
final int major;
|
||||
final int minor;
|
||||
|
||||
@override
|
||||
String get tagId => 'ibeacon:${uuid.toLowerCase()}-$major-$minor';
|
||||
}
|
||||
|
||||
class EddystoneUidConfig extends BeaconConfig {
|
||||
const EddystoneUidConfig({required this.namespace, required this.instance});
|
||||
|
||||
final String namespace;
|
||||
final String instance;
|
||||
|
||||
@override
|
||||
String get tagId => 'eddystone:$namespace$instance';
|
||||
}
|
||||
|
||||
class AltBeaconConfig extends BeaconConfig {
|
||||
const AltBeaconConfig({required this.beaconId});
|
||||
|
||||
final String beaconId;
|
||||
|
||||
@override
|
||||
String get tagId => 'altbeacon:$beaconId';
|
||||
}
|
||||
|
||||
// ─── Entry point ───────────────────────────────────────────────────────────
|
||||
|
||||
void showTagAddSheet(BuildContext context) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => const TagAddSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sheet ─────────────────────────────────────────────────────────────────
|
||||
|
||||
enum _AddMode { none, scan, configure }
|
||||
|
||||
class TagAddSheet extends ConsumerStatefulWidget {
|
||||
const TagAddSheet({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<TagAddSheet> createState() => _TagAddSheetState();
|
||||
}
|
||||
|
||||
class _TagAddSheetState extends ConsumerState<TagAddSheet> {
|
||||
late final PageController _pageController;
|
||||
final _scanner = TagScanner();
|
||||
late Stream<TagScanResult> _scanStream;
|
||||
int _scanGeneration = 0;
|
||||
|
||||
_AddMode _mode = _AddMode.none;
|
||||
BeaconConfig? _beaconConfig;
|
||||
TagScanResult? _selected;
|
||||
final _nameCtrl = TextEditingController();
|
||||
bool _creating = false;
|
||||
String? _createError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pageController = PageController();
|
||||
_newScanStream();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _newScanStream() {
|
||||
_scanStream = _scanner.scan();
|
||||
_scanGeneration++;
|
||||
}
|
||||
|
||||
void _animate(int page) => _pageController.animateToPage(
|
||||
page,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────────
|
||||
|
||||
void _pickMode(_AddMode mode) {
|
||||
setState(() => _mode = mode);
|
||||
if (mode == _AddMode.scan) {
|
||||
_newScanStream();
|
||||
_pageController.jumpToPage(3);
|
||||
} else {
|
||||
_animate(1);
|
||||
}
|
||||
}
|
||||
|
||||
void _pickStandard(BeaconType type) {
|
||||
setState(() => _beaconConfig = BeaconConfig.generate(type));
|
||||
_animate(2);
|
||||
}
|
||||
|
||||
void _nextFromConfigure() {
|
||||
_newScanStream();
|
||||
setState(() {});
|
||||
_animate(3);
|
||||
}
|
||||
|
||||
void _selectBeacon(TagScanResult r) {
|
||||
setState(() => _selected = r);
|
||||
_animate(4);
|
||||
}
|
||||
|
||||
void _back() {
|
||||
final page = _pageController.page?.round() ?? 0;
|
||||
switch (page) {
|
||||
case 1:
|
||||
setState(() => _mode = _AddMode.none);
|
||||
_animate(0);
|
||||
case 2:
|
||||
_animate(1);
|
||||
case 3:
|
||||
if (_mode == _AddMode.configure) {
|
||||
_animate(2);
|
||||
} else {
|
||||
setState(() => _mode = _AddMode.none);
|
||||
_pageController.jumpToPage(0);
|
||||
}
|
||||
case 4:
|
||||
setState(() => _selected = null);
|
||||
_animate(3);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Create ────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _createTag() async {
|
||||
final selected = _selected;
|
||||
final name = _nameCtrl.text.trim();
|
||||
if (selected == null || name.isEmpty) return;
|
||||
|
||||
setState(() {
|
||||
_creating = true;
|
||||
_createError = null;
|
||||
});
|
||||
try {
|
||||
await ref
|
||||
.read(tagRepositoryProvider)
|
||||
.createTag(tag_id: selected.tagId, name: name);
|
||||
ref.invalidate(tagsProvider);
|
||||
if (mounted) Navigator.of(context, rootNavigator: true).pop();
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _createError = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _creating = false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.85,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: PageView(
|
||||
controller: _pageController,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: [
|
||||
_ModeSelectPage(onPick: _pickMode),
|
||||
_StandardPickPage(onBack: _back, onPick: _pickStandard),
|
||||
_ConfigFieldsPage(
|
||||
config: _beaconConfig,
|
||||
onBack: _back,
|
||||
onNext: _nextFromConfigure,
|
||||
),
|
||||
_ScanPage(
|
||||
key: ValueKey(_scanGeneration),
|
||||
stream: _scanStream,
|
||||
beaconConfig: _mode == _AddMode.configure
|
||||
? _beaconConfig
|
||||
: null,
|
||||
onBack: _back,
|
||||
onSelect: _selectBeacon,
|
||||
),
|
||||
_DetailsPage(
|
||||
selected: _selected,
|
||||
nameCtrl: _nameCtrl,
|
||||
creating: _creating,
|
||||
error: _createError,
|
||||
onBack: _back,
|
||||
onCreate: _createTag,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Page 0: Mode selection ─────────────────────────────────────────────────
|
||||
|
||||
class _ModeSelectPage extends StatelessWidget {
|
||||
const _ModeSelectPage({required this.onPick});
|
||||
|
||||
final ValueChanged<_AddMode> onPick;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_DragHandle(),
|
||||
const SizedBox(height: 12),
|
||||
Text('Add a tag', style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'How would you like to add a tag?',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_ModeCard(
|
||||
icon: Icons.bluetooth_searching,
|
||||
title: 'Scan for a tag',
|
||||
subtitle:
|
||||
'Find a nearby beacon that is already configured and broadcasting.',
|
||||
onTap: () => onPick(_AddMode.scan),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_ModeCard(
|
||||
icon: Icons.settings_remote_outlined,
|
||||
title: 'Configure a tag',
|
||||
subtitle:
|
||||
'Generate settings to program into a beacon app, then scan to confirm the beacon is broadcasting.',
|
||||
onTap: () => onPick(_AddMode.configure),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ModeCard extends StatelessWidget {
|
||||
const _ModeCard({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: theme.colorScheme.primary, size: 28),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Page 1: Beacon standard picker (configure path) ───────────────────────
|
||||
|
||||
class _StandardPickPage extends StatelessWidget {
|
||||
const _StandardPickPage({required this.onBack, required this.onPick});
|
||||
|
||||
final VoidCallback onBack;
|
||||
final ValueChanged<BeaconType> onPick;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_DragHandle(),
|
||||
const SizedBox(height: 4),
|
||||
_BackRow(onBack: onBack, title: 'Choose a standard'),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Select the beacon standard your tag supports.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_StandardTile(
|
||||
title: 'iBeacon',
|
||||
subtitle: 'Apple proximity beacon protocol. UUID + Major + Minor.',
|
||||
onTap: () => onPick(BeaconType.iBeacon),
|
||||
),
|
||||
_StandardTile(
|
||||
title: 'AltBeacon',
|
||||
subtitle:
|
||||
'Open beacon specification from Radius Networks. 20-byte Beacon ID.',
|
||||
onTap: () => onPick(BeaconType.altBeacon),
|
||||
),
|
||||
_StandardTile(
|
||||
title: 'Eddystone-UID',
|
||||
subtitle:
|
||||
"Google's open beacon format. 10-byte namespace + 6-byte instance.",
|
||||
onTap: () => onPick(BeaconType.eddystoneUid),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StandardTile extends StatelessWidget {
|
||||
const _StandardTile({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(title),
|
||||
subtitle: Text(subtitle),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Page 2: Config fields with copy icons (configure path) ────────────────
|
||||
|
||||
class _ConfigFieldsPage extends StatelessWidget {
|
||||
const _ConfigFieldsPage({
|
||||
required this.config,
|
||||
required this.onBack,
|
||||
required this.onNext,
|
||||
});
|
||||
|
||||
final BeaconConfig? config;
|
||||
final VoidCallback onBack;
|
||||
final VoidCallback onNext;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final cfg = config;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_DragHandle(),
|
||||
const SizedBox(height: 4),
|
||||
_BackRow(onBack: onBack, title: 'Configure your beacon'),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Copy these values into your beacon configuration app, then tap Next to scan for the beacon.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (cfg is IBeaconConfig) ...[
|
||||
_CopyField(label: 'Proximity UUID', value: cfg.uuid),
|
||||
_CopyField(label: 'Major', value: cfg.major.toString()),
|
||||
_CopyField(label: 'Minor', value: cfg.minor.toString()),
|
||||
] else if (cfg is EddystoneUidConfig) ...[
|
||||
_CopyField(label: 'Namespace', value: cfg.namespace),
|
||||
_CopyField(label: 'Instance', value: cfg.instance),
|
||||
] else if (cfg is AltBeaconConfig) ...[
|
||||
_CopyField(label: 'Beacon ID', value: cfg.beaconId),
|
||||
],
|
||||
const Spacer(),
|
||||
FilledButton(
|
||||
onPressed: cfg != null ? onNext : null,
|
||||
child: const Text('Next'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CopyField extends StatelessWidget {
|
||||
const _CopyField({required this.label, required this.value});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: theme.textTheme.labelSmall),
|
||||
Text(
|
||||
value,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
tooltip: 'Copy',
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: value));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('$label copied'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Page 3: BLE scan ───────────────────────────────────────────────────────
|
||||
|
||||
class _ScanPage extends StatefulWidget {
|
||||
const _ScanPage({
|
||||
super.key,
|
||||
required this.stream,
|
||||
required this.beaconConfig,
|
||||
required this.onBack,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
final Stream<TagScanResult> stream;
|
||||
final BeaconConfig? beaconConfig;
|
||||
final VoidCallback onBack;
|
||||
final ValueChanged<TagScanResult> onSelect;
|
||||
|
||||
@override
|
||||
State<_ScanPage> createState() => _ScanPageState();
|
||||
}
|
||||
|
||||
class _ScanPageState extends State<_ScanPage> {
|
||||
final _discovered = <String, TagScanResult>{};
|
||||
StreamSubscription<TagScanResult>? _sub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_sub = widget.stream.listen((result) {
|
||||
if (mounted) setState(() => _discovered[result.tagId] = result);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final beaconConfig = widget.beaconConfig;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_DragHandle(),
|
||||
const SizedBox(height: 4),
|
||||
_BackRow(onBack: widget.onBack, title: 'Scan for beacon'),
|
||||
if (beaconConfig != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Looking for the beacon you just configured. Tap it once it appears.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
if (_discovered.isNotEmpty)
|
||||
Padding(
|
||||
padding: const .symmetric(horizontal: 16, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'Nearby tags',
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const SizedBox.square(
|
||||
dimension: 12,
|
||||
child: CircularProgressIndicator(strokeWidth: 1.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const .symmetric(horizontal: 24),
|
||||
child: _discovered.isEmpty
|
||||
? Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Scanning for nearby beacons…',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
)
|
||||
: ListView(
|
||||
children: _discovered.values.map((r) {
|
||||
final isMatch =
|
||||
beaconConfig != null && r.tagId == beaconConfig.tagId;
|
||||
final dimmed = beaconConfig != null && !isMatch;
|
||||
|
||||
return Opacity(
|
||||
opacity: dimmed ? 0.4 : 1.0,
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
tileColor: isMatch
|
||||
? theme.colorScheme.primaryContainer.withValues(
|
||||
alpha: 0.5,
|
||||
)
|
||||
: null,
|
||||
leading: const Icon(
|
||||
Icons.badge,
|
||||
),
|
||||
title: Text(r.displayLabel),
|
||||
subtitle: Text('${r.rssi} dBm'),
|
||||
trailing: _BeaconTypeBadge(r.typeName),
|
||||
onTap: () => widget.onSelect(r),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BeaconTypeBadge extends StatelessWidget {
|
||||
const _BeaconTypeBadge(this.label);
|
||||
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Page 4: Name / details ─────────────────────────────────────────────────
|
||||
|
||||
class _DetailsPage extends StatelessWidget {
|
||||
const _DetailsPage({
|
||||
required this.selected,
|
||||
required this.nameCtrl,
|
||||
required this.creating,
|
||||
required this.error,
|
||||
required this.onBack,
|
||||
required this.onCreate,
|
||||
});
|
||||
|
||||
final TagScanResult? selected;
|
||||
final TextEditingController nameCtrl;
|
||||
final bool creating;
|
||||
final String? error;
|
||||
final VoidCallback onBack;
|
||||
final VoidCallback onCreate;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final r = selected;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_DragHandle(),
|
||||
const SizedBox(height: 4),
|
||||
_BackRow(onBack: onBack, title: 'Name this tag'),
|
||||
const SizedBox(height: 24),
|
||||
Padding(
|
||||
padding: const .symmetric(horizontal: 12),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Tag name',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => onCreate(),
|
||||
),
|
||||
if (r != null && false) ...[
|
||||
const SizedBox(height: 16),
|
||||
_InfoRow(label: 'Type', value: r.typeName),
|
||||
_InfoRow(label: 'Identifier', value: r.displayLabel),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
error!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
FilledButton(
|
||||
onPressed: creating ? null : onCreate,
|
||||
child: creating
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Add tag'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
const _InfoRow({required this.label, required this.value});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(label, style: Theme.of(context).textTheme.bodyMedium),
|
||||
SizedBox(width: 12),
|
||||
Flexible(
|
||||
fit: FlexFit.loose,
|
||||
child: Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Shared widgets ─────────────────────────────────────────────────────────
|
||||
|
||||
class _DragHandle extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BackRow extends StatelessWidget {
|
||||
const _BackRow({required this.onBack, required this.title});
|
||||
|
||||
final VoidCallback onBack;
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: onBack,
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(title, style: Theme.of(context).textTheme.titleLarge),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../domain/models/tag.dart';
|
||||
import '../../providers.dart';
|
||||
|
||||
void showTagDetailSheet(BuildContext context, int tagId) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => TagDetailSheet(tagId: tagId),
|
||||
);
|
||||
}
|
||||
|
||||
class TagDetailSheet extends ConsumerStatefulWidget {
|
||||
const TagDetailSheet({super.key, required this.tagId});
|
||||
|
||||
final int tagId;
|
||||
|
||||
@override
|
||||
ConsumerState<TagDetailSheet> createState() => _TagDetailSheetState();
|
||||
}
|
||||
|
||||
class _TagDetailSheetState extends ConsumerState<TagDetailSheet> {
|
||||
bool _editing = false;
|
||||
final _nameCtrl = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _saveName(Tag tag) async {
|
||||
final name = _nameCtrl.text.trim();
|
||||
setState(() => _editing = false);
|
||||
if (name.isEmpty || name == tag.name) return;
|
||||
await ref
|
||||
.read(tagRepositoryProvider)
|
||||
.updateTag(tag.id, name: name);
|
||||
ref.invalidate(tagProvider(tag.id));
|
||||
ref.invalidate(tagsProvider);
|
||||
}
|
||||
|
||||
Future<void> _delete(BuildContext context, Tag tag) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Remove tag?'),
|
||||
content: const Text('This will unenrol the tag from the system.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(ctx).colorScheme.error,
|
||||
),
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('Remove'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true && context.mounted) {
|
||||
await ref.read(tagRepositoryProvider).deleteTag(tag.id);
|
||||
ref.invalidate(tagsProvider);
|
||||
if (context.mounted) Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tagAsync = ref.watch(tagProvider(widget.tagId));
|
||||
final occupancy = ref.watch(roomOccupancyProvider);
|
||||
|
||||
return tagAsync.when(
|
||||
loading: () => const SizedBox(
|
||||
height: 200,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (e, _) => SizedBox(
|
||||
height: 200,
|
||||
child: Center(child: Text(e.toString())),
|
||||
),
|
||||
data: (tag) {
|
||||
if (!_editing) _nameCtrl.text = tag.name;
|
||||
|
||||
int? currentRoomId;
|
||||
for (final entry in (occupancy.valueOrNull ?? {}).entries) {
|
||||
if (entry.value.contains(tag.tagId)) {
|
||||
currentRoomId = entry.key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
24,
|
||||
12,
|
||||
24,
|
||||
MediaQuery.of(context).viewInsets.bottom + 24,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _editing
|
||||
? TextField(
|
||||
controller: _nameCtrl,
|
||||
autofocus: true,
|
||||
style:
|
||||
Theme.of(context).textTheme.titleLarge,
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onSubmitted: (_) => _saveName(tag),
|
||||
)
|
||||
: Text(
|
||||
tag.name,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
|
||||
tooltip: _editing ? 'Save name' : 'Rename',
|
||||
onPressed: _editing
|
||||
? () => _saveName(tag)
|
||||
: () => setState(() {
|
||||
_editing = true;
|
||||
_nameCtrl.text = tag.name;
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_InfoRow(label: 'Tag ID', value: tag.tagId),
|
||||
_InfoRow(
|
||||
label: 'Current room',
|
||||
value: currentRoomId != null
|
||||
? 'Room $currentRoomId'
|
||||
: 'Not detected',
|
||||
),
|
||||
if (tag.lastSeen != null)
|
||||
_InfoRow(
|
||||
label: 'Last seen',
|
||||
value: _formatLastSeen(tag.lastSeen!),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
onPressed: () => _delete(context, tag),
|
||||
child: const Text('Remove tag'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
const _InfoRow({required this.label, required this.value});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(label,
|
||||
style: Theme.of(context).textTheme.bodySmall),
|
||||
),
|
||||
Text(value, style: Theme.of(context).textTheme.bodyMedium),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatLastSeen(DateTime dt) {
|
||||
final diff = DateTime.now().difference(dt);
|
||||
if (diff.inSeconds < 60) return 'Just now';
|
||||
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||
return '${diff.inDays}d ago';
|
||||
}
|
||||
@@ -1,23 +1,69 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../domain/models/tag.dart';
|
||||
import '../../providers.dart';
|
||||
import 'tag_add_sheet.dart';
|
||||
import 'tag_detail_sheet.dart';
|
||||
|
||||
class TagListScreen extends ConsumerWidget {
|
||||
const TagListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// TODO: replace Placeholder with AsyncValue-driven list.
|
||||
// final tags = ref.watch(tagsProvider); // define a FutureProvider
|
||||
final tags = ref.watch(tagsProvider);
|
||||
final occupancy = ref.watch(roomOccupancyProvider);
|
||||
|
||||
// Invert occupancy map: tagId -> roomId
|
||||
final tagRoomMap = <String, int>{};
|
||||
for (final entry in (occupancy.valueOrNull ?? {}).entries) {
|
||||
for (final tagId in entry.value) {
|
||||
tagRoomMap[tagId] = entry.key;
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Tags')),
|
||||
body: const Placeholder(),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () {
|
||||
// TODO: show tag enrollment sheet (BLE scan + manual ID fallback).
|
||||
body: tags.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text(e.toString())),
|
||||
data: (list) {
|
||||
if (list.isEmpty) {
|
||||
return const Center(child: Text('No tags enrolled yet'));
|
||||
}
|
||||
return ListView(
|
||||
children: list
|
||||
.map((t) => _TagTile(
|
||||
tag: t,
|
||||
roomId: tagRoomMap[t.tagId],
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => showTagAddSheet(context),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TagTile extends StatelessWidget {
|
||||
const _TagTile({required this.tag, required this.roomId});
|
||||
|
||||
final Tag tag;
|
||||
final int? roomId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDetected = roomId != null;
|
||||
return ListTile(
|
||||
leading: Icon(isDetected ? Icons.label : Icons.label_outline),
|
||||
title: Text(tag.name),
|
||||
subtitle: Text(isDetected ? 'Room $roomId' : 'Not detected'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => showTagDetailSheet(context, tag.id),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,19 @@ final sensorProvider =
|
||||
return ref.watch(sensorRepositoryProvider).getSensor(id);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tag data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
final tagsProvider = FutureProvider.autoDispose<List<Tag>>((ref) {
|
||||
return ref.watch(tagRepositoryProvider).getTags();
|
||||
});
|
||||
|
||||
final tagProvider =
|
||||
FutureProvider.autoDispose.family<Tag, int>((ref, id) {
|
||||
return ref.watch(tagRepositoryProvider).getTag(id);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-tab UI state
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -171,6 +184,12 @@ final tagPositionsProvider = StreamProvider<List<TagPosition>>((ref) {
|
||||
return repo.watchPositions();
|
||||
});
|
||||
|
||||
/// Accumulated room occupancy: room_id → [tag_id, ...].
|
||||
/// Updated incrementally as `occupancy_changed` events arrive on rooms:occupancy.
|
||||
final roomOccupancyProvider = StreamProvider<Map<int, List<String>>>((ref) {
|
||||
return ref.watch(tagRepositoryProvider).watchRoomOccupancy();
|
||||
});
|
||||
|
||||
final particleCloudProvider = StreamProvider<List<Particle>>((ref) {
|
||||
final repo = ref.watch(tagRepositoryProvider);
|
||||
return repo.watchParticleCloud();
|
||||
|
||||
Reference in New Issue
Block a user