feat: implement tag CRUD UI

This commit is contained in:
2026-05-16 17:43:26 +02:00
parent 568a851d07
commit b3f199b431
4 changed files with 1133 additions and 6 deletions
+849
View File
@@ -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),
],
);
}
}
+213
View File
@@ -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';
}
+52 -6
View File
@@ -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),
);
}
}
+19
View File
@@ -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();