From b3f199b43159b8833f42f841c2c4078b3b4f6c0b Mon Sep 17 00:00:00 2001 From: dvdrw Date: Sat, 16 May 2026 17:43:26 +0200 Subject: [PATCH] feat: implement tag CRUD UI --- lib/features/tags/tag_add_sheet.dart | 849 ++++++++++++++++++++++++ lib/features/tags/tag_detail_sheet.dart | 213 ++++++ lib/features/tags/tag_list_screen.dart | 58 +- lib/providers.dart | 19 + 4 files changed, 1133 insertions(+), 6 deletions(-) create mode 100644 lib/features/tags/tag_add_sheet.dart create mode 100644 lib/features/tags/tag_detail_sheet.dart diff --git a/lib/features/tags/tag_add_sheet.dart b/lib/features/tags/tag_add_sheet.dart new file mode 100644 index 0000000..e3f92b5 --- /dev/null +++ b/lib/features/tags/tag_add_sheet.dart @@ -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.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 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( + 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 createState() => _TagAddSheetState(); +} + +class _TagAddSheetState extends ConsumerState { + late final PageController _pageController; + final _scanner = TagScanner(); + late Stream _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 _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 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 stream; + final BeaconConfig? beaconConfig; + final VoidCallback onBack; + final ValueChanged onSelect; + + @override + State<_ScanPage> createState() => _ScanPageState(); +} + +class _ScanPageState extends State<_ScanPage> { + final _discovered = {}; + StreamSubscription? _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), + ], + ); + } +} diff --git a/lib/features/tags/tag_detail_sheet.dart b/lib/features/tags/tag_detail_sheet.dart new file mode 100644 index 0000000..fa529dd --- /dev/null +++ b/lib/features/tags/tag_detail_sheet.dart @@ -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( + 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 createState() => _TagDetailSheetState(); +} + +class _TagDetailSheetState extends ConsumerState { + bool _editing = false; + final _nameCtrl = TextEditingController(); + + @override + void dispose() { + _nameCtrl.dispose(); + super.dispose(); + } + + Future _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 _delete(BuildContext context, Tag tag) async { + final confirmed = await showDialog( + 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'; +} diff --git a/lib/features/tags/tag_list_screen.dart b/lib/features/tags/tag_list_screen.dart index 24c4979..ad91841 100644 --- a/lib/features/tags/tag_list_screen.dart +++ b/lib/features/tags/tag_list_screen.dart @@ -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 = {}; + 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), + ); + } +} \ No newline at end of file diff --git a/lib/providers.dart b/lib/providers.dart index 61ffe87..b4bbb10 100644 --- a/lib/providers.dart +++ b/lib/providers.dart @@ -145,6 +145,19 @@ final sensorProvider = return ref.watch(sensorRepositoryProvider).getSensor(id); }); +// --------------------------------------------------------------------------- +// Tag data +// --------------------------------------------------------------------------- + +final tagsProvider = FutureProvider.autoDispose>((ref) { + return ref.watch(tagRepositoryProvider).getTags(); +}); + +final tagProvider = + FutureProvider.autoDispose.family((ref, id) { + return ref.watch(tagRepositoryProvider).getTag(id); +}); + // --------------------------------------------------------------------------- // Cross-tab UI state // --------------------------------------------------------------------------- @@ -171,6 +184,12 @@ final tagPositionsProvider = StreamProvider>((ref) { return repo.watchPositions(); }); +/// Accumulated room occupancy: room_id → [tag_id, ...]. +/// Updated incrementally as `occupancy_changed` events arrive on rooms:occupancy. +final roomOccupancyProvider = StreamProvider>>((ref) { + return ref.watch(tagRepositoryProvider).watchRoomOccupancy(); +}); + final particleCloudProvider = StreamProvider>((ref) { final repo = ref.watch(tagRepositoryProvider); return repo.watchParticleCloud();