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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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 {
|
class TagListScreen extends ConsumerWidget {
|
||||||
const TagListScreen({super.key});
|
const TagListScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
// TODO: replace Placeholder with AsyncValue-driven list.
|
final tags = ref.watch(tagsProvider);
|
||||||
// final tags = ref.watch(tagsProvider); // define a FutureProvider
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Tags')),
|
appBar: AppBar(title: const Text('Tags')),
|
||||||
body: const Placeholder(),
|
body: tags.when(
|
||||||
floatingActionButton: FloatingActionButton(
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
onPressed: () {
|
error: (e, _) => Center(child: Text(e.toString())),
|
||||||
// TODO: show tag enrollment sheet (BLE scan + manual ID fallback).
|
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),
|
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);
|
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
|
// Cross-tab UI state
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -171,6 +184,12 @@ final tagPositionsProvider = StreamProvider<List<TagPosition>>((ref) {
|
|||||||
return repo.watchPositions();
|
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 particleCloudProvider = StreamProvider<List<Particle>>((ref) {
|
||||||
final repo = ref.watch(tagRepositoryProvider);
|
final repo = ref.watch(tagRepositoryProvider);
|
||||||
return repo.watchParticleCloud();
|
return repo.watchParticleCloud();
|
||||||
|
|||||||
Reference in New Issue
Block a user