init: rough companion app stub

This commit is contained in:
2026-05-07 18:35:58 +02:00
commit 5f017ac05d
73 changed files with 3520 additions and 0 deletions
@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/sources/ble/ble_provisioner.dart';
// Shared bottom sheet used by onboarding and the main sensor screens.
// Flow: scan → select device → enter WiFi credentials → provision → place on map.
class BleProvisionSheet extends ConsumerStatefulWidget {
const BleProvisionSheet({super.key});
@override
ConsumerState<BleProvisionSheet> createState() => _BleProvisionSheetState();
}
class _BleProvisionSheetState extends ConsumerState<BleProvisionSheet> {
final _provisioner = BleProvisioner();
final _ssidController = TextEditingController();
final _wifiPasswordController = TextEditingController();
BleScanResult? _selected;
bool _provisioning = false;
String? _error;
@override
void dispose() {
_provisioner.dispose();
_ssidController.dispose();
_wifiPasswordController.dispose();
super.dispose();
}
Future<void> _provision() async {
if (_selected == null) return;
setState(() {
_provisioning = true;
_error = null;
});
try {
await _provisioner.provision(
_selected!.deviceId,
ssid: _ssidController.text.trim(),
wifiPassword: _wifiPasswordController.text,
);
// TODO: poll localiserd until sensor appears, then prompt placement on map.
if (mounted) Navigator.of(context).pop();
} catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _provisioning = false);
}
}
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.6,
maxChildSize: 0.9,
builder: (context, scrollController) => Padding(
padding: EdgeInsets.fromLTRB(
24,
16,
24,
MediaQuery.of(context).viewInsets.bottom + 24,
),
child: ListView(
controller: scrollController,
children: [
Text('Add sensor',
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
// Scan results
// TODO: StreamBuilder on _provisioner.scan() — show a list of
// BleScanResult tiles; tapping one sets _selected.
const Text('Nearby ESP32 devices'),
const SizedBox(height: 8),
const Placeholder(fallbackHeight: 120),
const SizedBox(height: 24),
if (_selected != null) ...[
Text('Selected: ${_selected!.name}'),
const SizedBox(height: 16),
TextField(
controller: _ssidController,
decoration: const InputDecoration(
labelText: 'WiFi SSID',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _wifiPasswordController,
decoration: const InputDecoration(
labelText: 'WiFi password',
border: OutlineInputBorder(),
),
obscureText: true,
),
const SizedBox(height: 16),
],
if (_error != null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(_error!,
style: TextStyle(
color: Theme.of(context).colorScheme.error)),
),
FilledButton(
onPressed: (_selected == null || _provisioning) ? null : _provision,
child: _provisioning
? const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Provision & add'),
),
],
),
),
);
}
}
+139
View File
@@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../data/sources/localiser/realtime_data_client.dart';
import '../../providers.dart';
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
bool _loading = false;
String? _error;
@override
void initState() {
super.initState();
_tryAutoLogin();
}
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _tryAutoLogin() async {
final store = ref.read(credentialStoreProvider);
final saved = await store.load();
if (saved == null || !mounted) return;
_usernameController.text = saved.username;
_passwordController.text = saved.password;
await _login(saved.username, saved.password, saveCredentials: false);
}
Future<void> _login(String username, String password,
{bool saveCredentials = true}) async {
setState(() {
_loading = true;
_error = null;
});
try {
final client = ref.read(sessionClientProvider);
final tokenResponse = await client.login(username, password);
final token = tokenResponse.token;
ref.read(authTokenProvider.notifier).state = token;
final config = ref.read(serverConfigProvider)!;
final realtime = RealtimeDataClient(config: config, token: token);
await realtime.connect();
ref.read(realtimeDataClientProvider.notifier).state = realtime;
if (saveCredentials) {
await ref
.read(credentialStoreProvider)
.save((username: username, password: password));
}
if (mounted) context.go('/floorplan');
} on Exception catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sign In')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: 'Username',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.username],
),
const SizedBox(height: 12),
TextField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Password',
border: OutlineInputBorder(),
),
obscureText: true,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.password],
onSubmitted: _loading
? null
: (_) => _login(
_usernameController.text.trim(),
_passwordController.text,
),
),
const SizedBox(height: 16),
if (_error != null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
FilledButton(
onPressed: _loading
? null
: () => _login(
_usernameController.text.trim(),
_passwordController.text,
),
child: _loading
? const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Sign in'),
),
],
),
),
);
}
}
@@ -0,0 +1,164 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../data/sources/localiser/onboarding_client.dart';
import '../../data/sources/mdns/mdns_discovery.dart';
import '../../domain/models/server_config.dart';
import '../../providers.dart';
class ServerDiscoveryScreen extends ConsumerStatefulWidget {
const ServerDiscoveryScreen({super.key});
@override
ConsumerState<ServerDiscoveryScreen> createState() =>
_ServerDiscoveryScreenState();
}
class _ServerDiscoveryScreenState extends ConsumerState<ServerDiscoveryScreen> {
final _hostController = TextEditingController();
final _portController = TextEditingController(text: '4000');
bool _connecting = false;
String? _error;
final _discovery = MdnsDiscovery();
final _discoveredServers = <ServerConfig>[];
StreamSubscription<ServerConfig>? _discoverySub;
@override
void initState() {
super.initState();
_discoverySub = _discovery.discover().listen(
(server) {
if (!_discoveredServers.contains(server)) {
setState(() => _discoveredServers.add(server));
}
},
onError: (_) {},
);
}
@override
void dispose() {
_discoverySub?.cancel();
_hostController.dispose();
_portController.dispose();
_discovery.stop();
super.dispose();
}
Future<void> _connect(ServerConfig config) async {
setState(() {
_connecting = true;
_error = null;
});
try {
final checklist =
await OnboardingClient(config: config).getChecklist();
ref.read(serverConfigProvider.notifier).state = config;
if (!mounted) return;
if (!checklist.hasAdmin) {
context.go('/onboarding');
} else {
context.go('/login');
}
} catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _connecting = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Connect to Server')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Discovered servers'),
const SizedBox(height: 8),
if (_discoveredServers.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(
child: Text(
'Scanning…',
style: TextStyle(color: Colors.grey),
),
),
)
else
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200),
child: ListView.builder(
shrinkWrap: true,
itemCount: _discoveredServers.length,
itemBuilder: (context, i) {
final server = _discoveredServers[i];
return ListTile(
title: Text(server.host),
subtitle: Text('Port ${server.port}'),
trailing: const Icon(Icons.chevron_right),
onTap: _connecting ? null : () => _connect(server),
);
},
),
),
const Divider(),
const SizedBox(height: 16),
const Text('Manual entry'),
const SizedBox(height: 8),
TextField(
controller: _hostController,
decoration: const InputDecoration(
labelText: 'Host / IP',
hintText: '192.168.1.100',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.url,
),
const SizedBox(height: 12),
TextField(
controller: _portController,
decoration: const InputDecoration(
labelText: 'Port',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
if (_error != null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
FilledButton(
onPressed: _connecting
? null
: () => _connect(ServerConfig(
host: _hostController.text.trim(),
port: int.tryParse(_portController.text) ?? 4000,
)),
child: _connecting
? const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Connect'),
),
],
),
),
);
}
}
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../domain/models/floor_plan_mode.dart';
import '../../providers.dart';
import '../ble_provision/ble_provision_sheet.dart';
import 'widgets/konva_web_view.dart';
class FloorPlanScreen extends ConsumerStatefulWidget {
const FloorPlanScreen({super.key});
@override
ConsumerState<FloorPlanScreen> createState() => _FloorPlanScreenState();
}
class _FloorPlanScreenState extends ConsumerState<FloorPlanScreen> {
final _konvaKey = GlobalKey<KonvaWebViewState>();
@override
Widget build(BuildContext context) {
final mode = ref.watch(floorPlanModeProvider);
// TODO: forward live tag positions into the WebView.
// ref.listen(tagPositionsProvider, (_, next) {
// next.whenData((positions) => _konvaKey.currentState?.updateTags(positions));
// });
// TODO: forward particle cloud updates into the WebView.
// ref.listen(particleCloudProvider, (_, next) {
// next.whenData((particles) => _konvaKey.currentState?.updateParticleCloud(particles));
// });
// TODO: react to selectedSensorIdProvider and highlight sensor in WebView.
// ref.listen(selectedSensorIdProvider, (_, id) {
// _konvaKey.currentState?.highlightSensor(id);
// });
return Scaffold(
appBar: AppBar(
title: const Text('Floor Plan'),
actions: [
IconButton(
tooltip: mode == FloorPlanMode.edit ? 'View mode' : 'Edit mode',
icon: Icon(
mode == FloorPlanMode.edit ? Icons.visibility : Icons.edit,
),
onPressed: () {
final next = mode == FloorPlanMode.edit
? FloorPlanMode.view
: FloorPlanMode.edit;
ref.read(floorPlanModeProvider.notifier).state = next;
_konvaKey.currentState?.setMode(next);
},
),
],
),
body: KonvaWebView(
key: _konvaKey,
mode: mode,
onSensorTapped: (id) {
ref.read(selectedSensorIdProvider.notifier).state = id;
// TODO: optionally navigate to sensor detail or show tooltip.
},
onSensorMoved: (id, position) {
// TODO: persist new position via sensorRepositoryProvider.
},
),
floatingActionButton: mode == FloorPlanMode.edit
? FloatingActionButton.extended(
icon: const Icon(Icons.add),
label: const Text('Add sensor'),
onPressed: () => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => const BleProvisionSheet(),
),
)
: null,
);
}
}
@@ -0,0 +1,82 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import '../../../domain/models/floor_plan_mode.dart';
import '../../../domain/models/tag.dart';
import '../../../domain/models/particle.dart';
import '../../../domain/models/position.dart';
class KonvaWebView extends StatefulWidget {
const KonvaWebView({
super.key,
required this.mode,
required this.onSensorTapped,
required this.onSensorMoved,
});
final FloorPlanMode mode;
final void Function(String sensorId) onSensorTapped;
final void Function(String sensorId, Position newPosition) onSensorMoved;
@override
State<KonvaWebView> createState() => KonvaWebViewState();
}
class KonvaWebViewState extends State<KonvaWebView> {
late final WebViewController _controller;
@override
void initState() {
super.initState();
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel('FlutterBridge', onMessageReceived: _onMessage)
..loadFlutterAsset('assets/konva/index.html');
}
void _onMessage(JavaScriptMessage message) {
final data = jsonDecode(message.message) as Map<String, dynamic>;
switch (data['type'] as String?) {
case 'sensorTapped':
widget.onSensorTapped(data['id'] as String);
case 'sensorMoved':
widget.onSensorMoved(
data['id'] as String,
Position(
x: (data['x'] as num).toDouble(),
y: (data['y'] as num).toDouble(),
),
);
}
}
Future<void> updateTags(List<TagPosition> positions) async {
final payload = jsonEncode(positions
.map((p) => {'tagId': p.tagId, 'x': p.position.x, 'y': p.position.y})
.toList());
await _controller.runJavaScript('window.companion.updateTags($payload)');
}
Future<void> updateParticleCloud(List<Particle> particles) async {
final payload = jsonEncode(particles
.map((p) => {'x': p.x, 'y': p.y, 'weight': p.weight})
.toList());
await _controller.runJavaScript('window.companion.updateCloud($payload)');
}
Future<void> highlightSensor(String? sensorId) async {
final id = sensorId == null ? 'null' : '"$sensorId"';
await _controller.runJavaScript('window.companion.highlightSensor($id)');
}
Future<void> setMode(FloorPlanMode mode) async {
await _controller.runJavaScript(
'window.companion.setMode("${mode.name}")',
);
}
@override
Widget build(BuildContext context) => WebViewWidget(controller: _controller);
}
@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'steps/step_admin_user.dart';
import 'steps/step_floor_plan.dart';
import 'steps/step_sensors.dart';
import 'steps/step_tags.dart';
import 'steps/step_done.dart';
class OnboardingScreen extends ConsumerStatefulWidget {
const OnboardingScreen({super.key});
@override
ConsumerState<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
int _step = 0;
void _advance() => setState(() => _step++);
@override
Widget build(BuildContext context) {
final steps = <Widget>[
StepAdminUser(onComplete: _advance),
StepFloorPlan(onComplete: _advance),
StepSensors(onComplete: _advance),
StepTags(onComplete: _advance),
StepDone(onComplete: () => context.go('/floorplan')),
];
return Scaffold(
appBar: AppBar(
title: const Text('Setup'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(4),
child: LinearProgressIndicator(
value: (_step + 1) / steps.length,
),
),
),
body: steps[_step],
);
}
}
@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../data/sources/localiser/realtime_data_client.dart';
import '../../../providers.dart';
class StepAdminUser extends ConsumerStatefulWidget {
const StepAdminUser({super.key, required this.onComplete});
final VoidCallback onComplete;
@override
ConsumerState<StepAdminUser> createState() => _StepAdminUserState();
}
class _StepAdminUserState extends ConsumerState<StepAdminUser> {
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
bool _loading = false;
String? _error;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _submit() async {
setState(() {
_loading = true;
_error = null;
});
try {
final username = _usernameController.text.trim();
final password = _passwordController.text;
final token = await ref.read(onboardingRepositoryProvider).createAdminUser(
username: username,
password: password,
);
ref.read(authTokenProvider.notifier).state = token;
final config = ref.read(serverConfigProvider)!;
final realtime = RealtimeDataClient(config: config, token: token);
await realtime.connect();
ref.read(realtimeDataClientProvider.notifier).state = realtime;
await ref
.read(credentialStoreProvider)
.save((username: username, password: password));
widget.onComplete();
} catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Create admin account',
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 24),
TextField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: 'Username',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Password',
border: OutlineInputBorder(),
),
obscureText: true,
),
const SizedBox(height: 16),
if (_error != null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(_error!,
style:
TextStyle(color: Theme.of(context).colorScheme.error)),
),
FilledButton(
onPressed: _loading ? null : _submit,
child: _loading
? const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Create account'),
),
],
),
);
}
}
@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
class StepDone extends StatelessWidget {
const StepDone({super.key, required this.onComplete});
final VoidCallback onComplete;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(Icons.check_circle_outline, size: 72),
const SizedBox(height: 24),
Text(
'Setup complete',
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
const Text(
'Your floor plan and sensors are configured. You can add more sensors and tags at any time from the main screen.',
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
FilledButton(
onPressed: onComplete,
child: const Text('Go to floor plan'),
),
],
),
);
}
}
@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
class StepFloorPlan extends StatelessWidget {
const StepFloorPlan({super.key, required this.onComplete});
final VoidCallback onComplete;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Draw floor plan',
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
// TODO: embed KonvaWebView in editor mode (no live overlays).
// User draws rooms, sets scale, then taps Continue.
const Expanded(child: Placeholder()),
const SizedBox(height: 16),
FilledButton(
onPressed: onComplete,
child: const Text('Continue'),
),
],
),
);
}
}
@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import '../../ble_provision/ble_provision_sheet.dart';
class StepSensors extends StatelessWidget {
const StepSensors({super.key, required this.onComplete});
final VoidCallback onComplete;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Enroll sensors', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
const Text('Add at least one sensor to continue.'),
const SizedBox(height: 16),
// TODO: list of already-enrolled sensors with placement status.
const Expanded(child: Placeholder()),
const SizedBox(height: 16),
OutlinedButton.icon(
icon: const Icon(Icons.bluetooth),
label: const Text('Add sensor'),
onPressed: () => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => const BleProvisionSheet(),
),
),
const SizedBox(height: 12),
FilledButton(
onPressed: onComplete,
child: const Text('Continue'),
),
],
),
);
}
}
@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
class StepTags extends StatelessWidget {
const StepTags({super.key, required this.onComplete});
final VoidCallback onComplete;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Enroll tags', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
const Text('Tags are optional here — you can enroll them later.'),
const SizedBox(height: 16),
// TODO: BLE scan list + enrolled tag list, similar to StepSensors.
const Expanded(child: Placeholder()),
const SizedBox(height: 16),
FilledButton(
onPressed: onComplete,
child: const Text('Continue'),
),
],
),
);
}
}
@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../providers.dart';
class SensorDetailScreen extends ConsumerWidget {
const SensorDetailScreen({super.key, required this.sensorId});
final String sensorId;
@override
Widget build(BuildContext context, WidgetRef ref) {
// TODO: fetch sensor via sensorRepositoryProvider.getSensor(sensorId).
return Scaffold(
appBar: AppBar(title: Text('Sensor $sensorId')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// TODO: display sensor fields (name, status, position, last seen).
const Placeholder(fallbackHeight: 200),
const SizedBox(height: 24),
OutlinedButton.icon(
icon: const Icon(Icons.map_outlined),
label: const Text('Locate on floor plan'),
onPressed: () {
ref.read(selectedSensorIdProvider.notifier).state = sensorId;
context.go('/floorplan');
},
),
const SizedBox(height: 12),
// TODO: re-provision button → show BleProvisionSheet pre-filled.
OutlinedButton.icon(
icon: const Icon(Icons.bluetooth),
label: const Text('Re-provision WiFi'),
onPressed: () {}, // TODO
),
const SizedBox(height: 12),
OutlinedButton.icon(
icon: const Icon(Icons.edit_outlined),
label: const Text('Rename'),
onPressed: () {}, // TODO
),
const Spacer(),
TextButton(
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
onPressed: () {}, // TODO: confirm dialog then delete
child: const Text('Delete sensor'),
),
],
),
),
);
}
}
@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../providers.dart';
import '../ble_provision/ble_provision_sheet.dart';
class SensorListScreen extends ConsumerWidget {
const SensorListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// TODO: replace Placeholder with AsyncValue-driven list.
// final sensors = ref.watch(sensorsProvider); // define a FutureProvider
final selectedId = ref.watch(selectedSensorIdProvider);
return Scaffold(
appBar: AppBar(title: const Text('Sensors')),
body: Column(
children: [
if (selectedId != null)
MaterialBanner(
content: Text('Sensor $selectedId selected on floor plan'),
actions: [
TextButton(
onPressed: () =>
ref.read(selectedSensorIdProvider.notifier).state = null,
child: const Text('Dismiss'),
),
TextButton(
onPressed: () => context.push('/sensors/$selectedId'),
child: const Text('Open'),
),
],
),
// TODO: ListView.builder with sensor tiles.
// Highlight tile whose id == selectedId.
const Expanded(child: Placeholder()),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => const BleProvisionSheet(),
),
child: const Icon(Icons.add),
),
);
}
}
@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../providers.dart';
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final config = ref.watch(serverConfigProvider);
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: ListView(
children: [
ListTile(
title: const Text('Server'),
subtitle: config == null
? const Text('Not connected')
: Text('${config.host}:${config.port}'),
trailing: const Icon(Icons.chevron_right),
onTap: () {}, // TODO: show server config sheet
),
const Divider(),
// TODO: admin account section (change password).
const AboutListTile(applicationName: 'Companion'),
],
),
);
}
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class MainShell extends StatelessWidget {
const MainShell({super.key, required this.shell});
final StatefulNavigationShell shell;
@override
Widget build(BuildContext context) {
return Scaffold(
body: shell,
bottomNavigationBar: NavigationBar(
selectedIndex: shell.currentIndex,
onDestinationSelected: (index) => shell.goBranch(
index,
initialLocation: index == shell.currentIndex,
),
destinations: const [
NavigationDestination(
icon: Icon(Icons.map_outlined),
selectedIcon: Icon(Icons.map),
label: 'Floor Plan',
),
NavigationDestination(
icon: Icon(Icons.sensors_outlined),
selectedIcon: Icon(Icons.sensors),
label: 'Sensors',
),
NavigationDestination(
icon: Icon(Icons.label_outline),
selectedIcon: Icon(Icons.label),
label: 'Tags',
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Settings',
),
],
),
);
}
}
+41
View File
@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class TagDetailScreen extends ConsumerWidget {
const TagDetailScreen({super.key, required this.tagId});
final String tagId;
@override
Widget build(BuildContext context, WidgetRef ref) {
// TODO: fetch tag via tagRepositoryProvider.getTag(tagId).
return Scaffold(
appBar: AppBar(title: Text('Tag $tagId')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// TODO: display tag fields (name, current room, last seen, position).
const Placeholder(fallbackHeight: 200),
const Spacer(),
OutlinedButton.icon(
icon: const Icon(Icons.edit_outlined),
label: const Text('Rename'),
onPressed: () {}, // TODO
),
const SizedBox(height: 12),
TextButton(
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
onPressed: () {}, // TODO: confirm dialog then delete
child: const Text('Remove tag'),
),
],
),
),
);
}
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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
return Scaffold(
appBar: AppBar(title: const Text('Tags')),
body: const Placeholder(),
floatingActionButton: FloatingActionButton(
onPressed: () {
// TODO: show tag enrollment sheet (BLE scan + manual ID fallback).
},
child: const Icon(Icons.add),
),
);
}
}