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,6 @@
import '../../domain/models/floor_plan.dart';
abstract class FloorPlanRepository {
Future<FloorPlan?> getFloorPlan();
Future<FloorPlan> saveFloorPlan(FloorPlan plan);
}
@@ -0,0 +1,7 @@
import '../../domain/models/onboarding_status.dart';
abstract class OnboardingRepository {
Future<OnboardingStatus> getStatus();
/// Creates the first admin user and returns the issued JWT.
Future<String> createAdminUser({required String username, required String password});
}
@@ -0,0 +1,15 @@
import '../../domain/models/floor_plan.dart';
import '../sources/localiser/floor_client.dart';
import 'floor_plan_repository.dart';
class PhoenixFloorPlanRepository implements FloorPlanRepository {
const PhoenixFloorPlanRepository({required this.client});
final FloorClient client;
@override
Future<FloorPlan?> getFloorPlan() => throw UnimplementedError();
@override
Future<FloorPlan> saveFloorPlan(FloorPlan plan) => throw UnimplementedError();
}
@@ -0,0 +1,27 @@
import '../../domain/models/onboarding_status.dart';
import '../sources/localiser/onboarding_client.dart';
import 'onboarding_repository.dart';
class PhoenixOnboardingRepository implements OnboardingRepository {
const PhoenixOnboardingRepository({required this.client});
final OnboardingClient client;
@override
Future<OnboardingStatus> getStatus() async {
final checklist = await client.getChecklist();
if (!checklist.hasAdmin) return OnboardingStatus.notStarted;
if (!checklist.hasFloors) return OnboardingStatus.awaitingFloorPlan;
if (!checklist.hasSensorsPlaced) return OnboardingStatus.awaitingFirstSensor;
return OnboardingStatus.complete;
}
@override
Future<String> createAdminUser({
required String username,
required String password,
}) async {
final response = await client.setup(username, password);
return response.token;
}
}
@@ -0,0 +1,27 @@
import '../../domain/models/sensor.dart';
import '../../domain/models/position.dart';
import '../sources/localiser/sensor_client.dart';
import 'sensor_repository.dart';
class PhoenixSensorRepository implements SensorRepository {
const PhoenixSensorRepository({required this.client});
final SensorClient client;
@override
Future<List<Sensor>> getSensors() => throw UnimplementedError();
@override
Future<Sensor> getSensor(String id) => throw UnimplementedError();
@override
Future<Sensor> createSensor({required String name, required Position position}) =>
throw UnimplementedError();
@override
Future<Sensor> updateSensor(String id, {String? name, Position? position}) =>
throw UnimplementedError();
@override
Future<void> deleteSensor(String id) => throw UnimplementedError();
}
@@ -0,0 +1,37 @@
import '../../domain/models/tag.dart';
import '../../domain/models/particle.dart';
import '../sources/localiser/tag_client.dart';
import '../sources/localiser/realtime_data_client.dart';
import 'tag_repository.dart';
class PhoenixTagRepository implements TagRepository {
const PhoenixTagRepository({
required this.tagClient,
required this.realtime,
});
final TagClient tagClient;
final RealtimeDataClient realtime;
@override
Future<List<Tag>> getTags() => throw UnimplementedError();
@override
Future<Tag> getTag(String id) => throw UnimplementedError();
@override
Future<Tag> createTag({required String id, required String name}) =>
throw UnimplementedError();
@override
Future<Tag> updateTag(String id, {String? name}) => throw UnimplementedError();
@override
Future<void> deleteTag(String id) => throw UnimplementedError();
@override
Stream<List<TagPosition>> watchPositions() => throw UnimplementedError();
@override
Stream<List<Particle>> watchParticleCloud() => throw UnimplementedError();
}
@@ -0,0 +1,10 @@
import '../../domain/models/sensor.dart';
import '../../domain/models/position.dart';
abstract class SensorRepository {
Future<List<Sensor>> getSensors();
Future<Sensor> getSensor(String id);
Future<Sensor> createSensor({required String name, required Position position});
Future<Sensor> updateSensor(String id, {String? name, Position? position});
Future<void> deleteSensor(String id);
}
+16
View File
@@ -0,0 +1,16 @@
import '../../domain/models/tag.dart';
import '../../domain/models/particle.dart';
abstract class TagRepository {
Future<List<Tag>> getTags();
Future<Tag> getTag(String id);
Future<Tag> createTag({required String id, required String name});
Future<Tag> updateTag(String id, {String? name});
Future<void> deleteTag(String id);
/// Live stream of all tag positions, pushed by localiserd over Phoenix channel.
Stream<List<TagPosition>> watchPositions();
/// Live stream of particle filter cloud snapshots.
Stream<List<Particle>> watchParticleCloud();
}
+37
View File
@@ -0,0 +1,37 @@
// Uses flutter_blue_plus to drive the ESP-IDF Unified Provisioning GATT protocol.
// No BLE pairing or password required.
//
// Android: minSdkVersion must be ≥ 21 in android/app/build.gradle.
class BleScanResult {
const BleScanResult({
required this.deviceId,
required this.name,
required this.rssi,
});
final String deviceId;
final String name;
final int rssi;
}
class BleProvisioner {
// TODO: implement using flutter_blue_plus.
// Filter scan by the ESP-IDF provisioning service UUID advertised by your firmware.
/// Starts a BLE scan and emits discovered ESP32 provisioning devices.
Stream<BleScanResult> scan() => throw UnimplementedError();
Future<void> stopScan() async => throw UnimplementedError();
/// Connects to [deviceId] and sends WiFi credentials via the ESP-IDF
/// Unified Provisioning GATT profile (protobuf over BLE characteristic).
Future<void> provision(
String deviceId, {
required String ssid,
required String wifiPassword,
}) async =>
throw UnimplementedError();
void dispose() {}
}
@@ -0,0 +1,31 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
typedef Credentials = ({String username, String password});
class CredentialStore {
static const _usernameKey = 'localiserd_username';
static const _passwordKey = 'localiserd_password';
final _storage = const FlutterSecureStorage();
Future<void> save(Credentials credentials) => Future.wait([
_storage.write(key: _usernameKey, value: credentials.username),
_storage.write(key: _passwordKey, value: credentials.password),
]).then((_) {});
Future<Credentials?> load() async {
final results = await Future.wait([
_storage.read(key: _usernameKey),
_storage.read(key: _passwordKey),
]);
final username = results[0];
final password = results[1];
if (username == null || password == null) return null;
return (username: username, password: password);
}
Future<void> clear() => Future.wait([
_storage.delete(key: _usernameKey),
_storage.delete(key: _passwordKey),
]).then((_) {});
}
@@ -0,0 +1,36 @@
import '../../../domain/models/server_config.dart';
import 'localiser_client.dart';
class FloorClient extends LocaliserdClient {
FloorClient({required super.config, required String super.token});
Future<List<dynamic>> getFloors() async =>
await get('/api/floors') as List<dynamic>;
Future<Map<String, dynamic>> getFloor(int id) async =>
await get('/api/floors/$id') as Map<String, dynamic>;
Future<Map<String, dynamic>> createFloor(Map<String, dynamic> params) async =>
await post('/api/floors', params) as Map<String, dynamic>;
Future<Map<String, dynamic>> updateFloor(
int id, Map<String, dynamic> params) async =>
await patch('/api/floors/$id', params) as Map<String, dynamic>;
Future<void> deleteFloor(int id) => delete('/api/floors/$id');
Future<List<dynamic>> getRooms(int floorId) async =>
await get('/api/floors/$floorId/rooms') as List<dynamic>;
Future<Map<String, dynamic>> createRoom(
int floorId, Map<String, dynamic> params) async =>
await post('/api/floors/$floorId/rooms', params) as Map<String, dynamic>;
Future<Map<String, dynamic>> updateRoom(
int floorId, int id, Map<String, dynamic> params) async =>
await patch('/api/floors/$floorId/rooms/$id', params)
as Map<String, dynamic>;
Future<void> deleteRoom(int floorId, int id) =>
delete('/api/floors/$floorId/rooms/$id');
}
@@ -0,0 +1,68 @@
import 'dart:convert';
import 'dart:io';
import '../../../domain/models/server_config.dart';
class ApiException implements Exception {
const ApiException(this.statusCode, this.message);
final int statusCode;
final String message;
@override
String toString() => 'ApiException $statusCode: $message';
}
abstract class LocaliserdClient {
const LocaliserdClient({required this.config, this.token});
final ServerConfig config;
/// Null for unauthenticated clients (session, onboarding setup).
final String? token;
Uri _uri(String path) => Uri(
scheme: 'http',
host: config.host,
port: config.port,
path: path,
);
Future<dynamic> get(String path) => _request('GET', path);
Future<dynamic> post(String path, [Object? body]) => _request('POST', path, body);
Future<dynamic> put(String path, [Object? body]) => _request('PUT', path, body);
Future<dynamic> patch(String path, [Object? body]) => _request('PATCH', path, body);
Future<void> delete(String path) => _request('DELETE', path).then((_) {});
/// Like [delete] but returns the response body (for endpoints that do so).
Future<dynamic> deleteBody(String path) => _request('DELETE', path);
Future<dynamic> _request(String method, String path, [Object? body]) async {
final http = HttpClient();
try {
final request = await http.openUrl(method, _uri(path));
request.headers.contentType = ContentType.json;
if (token != null) {
request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $token');
}
if (body != null) request.write(jsonEncode(body));
final response = await request.close();
if (response.statusCode == 204) return null;
final responseBody = await response.transform(utf8.decoder).join();
if (response.statusCode >= 400) {
final parsed = responseBody.isNotEmpty
? jsonDecode(responseBody) as Map<String, dynamic>?
: null;
throw ApiException(
response.statusCode,
parsed?['error']?.toString() ?? responseBody,
);
}
return responseBody.isEmpty ? null : jsonDecode(responseBody);
} finally {
http.close();
}
}
}
@@ -0,0 +1,47 @@
import '../../../domain/models/auth.dart';
import '../../../domain/models/server_config.dart';
import 'localiser_client.dart';
class OnboardingChecklist {
const OnboardingChecklist({
required this.hasAdmin,
required this.hasFloors,
required this.hasRooms,
required this.hasSensorsPlaced,
required this.hasTags,
});
final bool hasAdmin;
final bool hasFloors;
final bool hasRooms;
final bool hasSensorsPlaced;
final bool hasTags;
factory OnboardingChecklist.fromJson(Map<String, dynamic> json) =>
OnboardingChecklist(
hasAdmin: json['has_admin'] as bool,
hasFloors: json['has_floors'] as bool,
hasRooms: json['has_rooms'] as bool,
hasSensorsPlaced: json['has_sensors_placed'] as bool,
hasTags: json['has_tags'] as bool,
);
}
class OnboardingClient extends LocaliserdClient {
OnboardingClient({required super.config});
Future<OnboardingChecklist> getChecklist() async {
final json = await get('/api/onboarding') as Map<String, dynamic>;
return OnboardingChecklist.fromJson(json);
}
/// Creates the first admin user. Only succeeds when no users exist yet.
/// Returns the JWT so the caller can immediately authenticate.
Future<TokenResponse> setup(String username, String password) async {
final json = await post('/api/setup', {
'username': username,
'password': password,
}) as Map<String, dynamic>;
return TokenResponse.fromJson(json);
}
}
@@ -0,0 +1,72 @@
import 'package:phoenix_socket/phoenix_socket.dart';
import '../../../domain/models/server_config.dart';
class RealtimeDataClient {
RealtimeDataClient({required this.config, required this.token});
final ServerConfig config;
final String token;
PhoenixSocket? _socket;
final _channels = <String, PhoenixChannel>{};
Future<void> connect() async {
_socket = PhoenixSocket(
config.wsUrl,
socketOptions: PhoenixSocketOptions(
params: {'token': token},
),
);
await _socket!.connect();
}
Future<void> disconnect() async {
for (final channel in _channels.values) {
channel.leave();
}
_channels.clear();
_socket?.close();
_socket = null;
}
bool get isConnected => _socket?.isConnected ?? false;
/// Joins [topic] (if not already joined) and returns a stream of message
/// payloads for that topic. The stream stays open until [disconnect] is
/// called or the underlying socket closes.
Stream<Map<String, dynamic>> channel(
String topic, {
Map<String, dynamic> params = const {},
}) {
final socket = _socket;
if (socket == null) throw StateError('RealtimeDataClient not connected');
final channel = _channels.putIfAbsent(
topic,
() {
final ch = socket.addChannel(topic: topic, parameters: params);
ch.join();
return ch;
},
);
return channel.messages
.where((msg) => msg.event.value != 'phx_reply')
.map((msg) => msg.payload ?? const {});
}
/// Pushes [event] on [topic] and waits for the server reply.
/// The channel must have been joined first via [channel].
Future<Map<String, dynamic>> push(
String topic,
String event,
Map<String, dynamic> payload,
) async {
final ch = _channels[topic];
if (ch == null) throw StateError('Channel $topic has not been joined');
final reply = await ch.push(event, payload).future;
return (reply.response as Map<String, dynamic>?) ?? const {};
}
}
@@ -0,0 +1,37 @@
import '../../../domain/models/server_config.dart';
import 'localiser_client.dart';
class SensorClient extends LocaliserdClient {
SensorClient({required ServerConfig config, required String token})
: super(config: config, token: token);
Future<List<dynamic>> getSensors() async =>
await get('/api/sensors') as List<dynamic>;
Future<List<dynamic>> getUnplacedSensors() async =>
await get('/api/sensors/unplaced') as List<dynamic>;
Future<Map<String, dynamic>> getSensor(int id) async =>
await get('/api/sensors/$id') as Map<String, dynamic>;
Future<Map<String, dynamic>> updateSensor(
int id, Map<String, dynamic> params) async =>
await put('/api/sensors/$id', params) as Map<String, dynamic>;
Future<void> deleteSensor(int id) => delete('/api/sensors/$id');
Future<Map<String, dynamic>> placeSensor(
int id, Map<String, dynamic> params) async =>
await put('/api/sensors/$id/place', params) as Map<String, dynamic>;
Future<Map<String, dynamic>> unplaceSensor(int id) async =>
await deleteBody('/api/sensors/$id/place') as Map<String, dynamic>;
Future<Map<String, dynamic>> startCalibration(
int id, double referenceDistance) async =>
await post('/api/sensors/$id/calibration/start',
{'reference_distance': referenceDistance}) as Map<String, dynamic>;
Future<Map<String, dynamic>> stopCalibration(int id) async =>
await post('/api/sensors/$id/calibration/stop') as Map<String, dynamic>;
}
@@ -0,0 +1,15 @@
import '../../../domain/models/auth.dart';
import '../../../domain/models/server_config.dart';
import 'localiser_client.dart';
class SessionClient extends LocaliserdClient {
SessionClient({required super.config});
Future<TokenResponse> login(String username, String password) async {
final json = await post('/api/session', {
'username': username,
'password': password,
}) as Map<String, dynamic>;
return TokenResponse.fromJson(json);
}
}
@@ -0,0 +1,21 @@
import '../../../domain/models/server_config.dart';
import 'localiser_client.dart';
class TagClient extends LocaliserdClient {
TagClient({required super.config, required String super.token});
Future<List<dynamic>> getTags() async =>
await get('/api/tags') as List<dynamic>;
Future<Map<String, dynamic>> getTag(int id) async =>
await get('/api/tags/$id') as Map<String, dynamic>;
Future<Map<String, dynamic>> createTag(Map<String, dynamic> params) async =>
await post('/api/tags', params) as Map<String, dynamic>;
Future<Map<String, dynamic>> updateTag(
int id, Map<String, dynamic> params) async =>
await patch('/api/tags/$id', params) as Map<String, dynamic>;
Future<void> deleteTag(int id) => delete('/api/tags/$id');
}
+41
View File
@@ -0,0 +1,41 @@
import 'dart:async';
import 'package:multicast_dns/multicast_dns.dart';
import '../../../domain/models/server_config.dart';
class MdnsDiscovery {
static const String serviceType = '_localiserd._tcp';
MDnsClient? _client;
Stream<ServerConfig> discover() async* {
_client?.stop();
final client = MDnsClient();
_client = client;
await client.start();
try {
await for (final ptr in client.lookup<PtrResourceRecord>(
ResourceRecordQuery.serverPointer(serviceType),
)) {
await for (final srv in client.lookup<SrvResourceRecord>(
ResourceRecordQuery.service(ptr.domainName),
)) {
await for (final ip in client.lookup<IPAddressResourceRecord>(
ResourceRecordQuery.addressIPv4(srv.target),
)) {
yield ServerConfig(host: ip.address.address, port: srv.port);
}
}
}
} finally {
client.stop();
if (_client == client) _client = null;
}
}
Future<void> stop() async {
_client?.stop();
_client = null;
}
}
+25
View File
@@ -0,0 +1,25 @@
class User {
const User({required this.id, required this.username, required this.isAdmin});
final int id;
final String username;
final bool isAdmin;
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'] as int,
username: json['username'] as String,
isAdmin: json['is_admin'] as bool,
);
}
class TokenResponse {
const TokenResponse({required this.token, required this.user});
final String token;
final User user;
factory TokenResponse.fromJson(Map<String, dynamic> json) => TokenResponse(
token: json['token'] as String,
user: User.fromJson(json['user'] as Map<String, dynamic>),
);
}
+68
View File
@@ -0,0 +1,68 @@
import 'position.dart';
class Room {
const Room({required this.id, required this.name, required this.polygon});
final String id;
final String name;
/// Polygon vertices in normalised 0..1 coordinates.
final List<Position> polygon;
Room copyWith({String? name, List<Position>? polygon}) =>
Room(id: id, name: name ?? this.name, polygon: polygon ?? this.polygon);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'polygon': polygon.map((p) => p.toJson()).toList(),
};
factory Room.fromJson(Map<String, dynamic> json) => Room(
id: json['id'] as String,
name: json['name'] as String,
polygon: (json['polygon'] as List)
.map((p) => Position.fromJson(p as Map<String, dynamic>))
.toList(),
);
}
class FloorPlan {
const FloorPlan({
required this.id,
required this.floorId,
required this.metersPerUnit,
required this.rooms,
});
final String id;
final String floorId;
/// Scale: how many real-world meters one normalised unit represents.
final double metersPerUnit;
final List<Room> rooms;
FloorPlan copyWith({double? metersPerUnit, List<Room>? rooms}) => FloorPlan(
id: id,
floorId: floorId,
metersPerUnit: metersPerUnit ?? this.metersPerUnit,
rooms: rooms ?? this.rooms,
);
Map<String, dynamic> toJson() => {
'id': id,
'floor_id': floorId,
'meters_per_unit': metersPerUnit,
'rooms': rooms.map((r) => r.toJson()).toList(),
};
factory FloorPlan.fromJson(Map<String, dynamic> json) => FloorPlan(
id: json['id'] as String,
floorId: json['floor_id'] as String,
metersPerUnit: (json['meters_per_unit'] as num).toDouble(),
rooms: (json['rooms'] as List)
.map((r) => Room.fromJson(r as Map<String, dynamic>))
.toList(),
);
}
+1
View File
@@ -0,0 +1 @@
enum FloorPlanMode { view, edit }
+13
View File
@@ -0,0 +1,13 @@
enum OnboardingStatus {
/// No admin user exists yet.
notStarted,
/// Admin user created, floor plan not yet saved.
awaitingFloorPlan,
/// Floor plan saved, no sensors enrolled yet.
awaitingFirstSensor,
/// At least one sensor enrolled; onboarding considered complete.
complete,
}
+17
View File
@@ -0,0 +1,17 @@
/// Single particle in a particle filter cloud snapshot.
class Particle {
const Particle({required this.x, required this.y, required this.weight});
/// Normalised 0..1 coordinates (same space as [Position]).
final double x;
final double y;
/// Unnormalised likelihood weight.
final double weight;
factory Particle.fromJson(Map<String, dynamic> json) => Particle(
x: (json['x'] as num).toDouble(),
y: (json['y'] as num).toDouble(),
weight: (json['weight'] as num).toDouble(),
);
}
+25
View File
@@ -0,0 +1,25 @@
// Normalised coordinates: x and y are in the range 0..1 relative to the floor plan canvas.
class Position {
const Position({required this.x, required this.y});
final double x;
final double y;
Position copyWith({double? x, double? y}) =>
Position(x: x ?? this.x, y: y ?? this.y);
// Key names should match localiserd API fields.
Map<String, dynamic> toJson() => {'x': x, 'y': y};
factory Position.fromJson(Map<String, dynamic> json) => Position(
x: (json['x'] as num).toDouble(),
y: (json['y'] as num).toDouble(),
);
@override
bool operator ==(Object other) =>
other is Position && other.x == x && other.y == y;
@override
int get hashCode => Object.hash(x, y);
}
+56
View File
@@ -0,0 +1,56 @@
import 'position.dart';
enum SensorStatus { online, offline, provisioning }
class Sensor {
const Sensor({
required this.id,
required this.name,
required this.floorId,
required this.position,
required this.status,
this.lastSeen,
});
final String id;
final String name;
final String floorId;
final Position position;
final SensorStatus status;
final DateTime? lastSeen;
Sensor copyWith({
String? name,
Position? position,
SensorStatus? status,
DateTime? lastSeen,
}) =>
Sensor(
id: id,
name: name ?? this.name,
floorId: floorId,
position: position ?? this.position,
status: status ?? this.status,
lastSeen: lastSeen ?? this.lastSeen,
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'floor_id': floorId,
'position': position.toJson(),
'status': status.name,
'last_seen': lastSeen?.toIso8601String(),
};
factory Sensor.fromJson(Map<String, dynamic> json) => Sensor(
id: json['id'] as String,
name: json['name'] as String,
floorId: json['floor_id'] as String,
position: Position.fromJson(json['position'] as Map<String, dynamic>),
status: SensorStatus.values.byName(json['status'] as String),
lastSeen: json['last_seen'] == null
? null
: DateTime.parse(json['last_seen'] as String),
);
}
+18
View File
@@ -0,0 +1,18 @@
class ServerConfig {
const ServerConfig({required this.host, required this.port});
final String host;
final int port;
String get wsUrl => 'ws://$host:$port/socket/websocket';
ServerConfig copyWith({String? host, int? port}) =>
ServerConfig(host: host ?? this.host, port: port ?? this.port);
@override
bool operator ==(Object other) =>
other is ServerConfig && other.host == host && other.port == port;
@override
int get hashCode => Object.hash(host, port);
}
+54
View File
@@ -0,0 +1,54 @@
import 'position.dart';
class Tag {
const Tag({
required this.id,
required this.name,
this.currentRoomId,
this.lastPosition,
this.lastSeen,
});
final String id;
final String name;
final String? currentRoomId;
final Position? lastPosition;
final DateTime? lastSeen;
Tag copyWith({String? name, String? currentRoomId, Position? lastPosition, DateTime? lastSeen}) =>
Tag(
id: id,
name: name ?? this.name,
currentRoomId: currentRoomId ?? this.currentRoomId,
lastPosition: lastPosition ?? this.lastPosition,
lastSeen: lastSeen ?? this.lastSeen,
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'current_room_id': currentRoomId,
'last_position': lastPosition?.toJson(),
'last_seen': lastSeen?.toIso8601String(),
};
factory Tag.fromJson(Map<String, dynamic> json) => Tag(
id: json['id'] as String,
name: json['name'] as String,
currentRoomId: json['current_room_id'] as String?,
lastPosition: json['last_position'] == null
? null
: Position.fromJson(json['last_position'] as Map<String, dynamic>),
lastSeen: json['last_seen'] == null
? null
: DateTime.parse(json['last_seen'] as String),
);
}
// Live position snapshot pushed from localiserd over Phoenix channel.
class TagPosition {
const TagPosition({required this.tagId, required this.position});
final String tagId;
final Position position;
}
@@ -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),
),
);
}
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'router.dart';
void main() {
runApp(const ProviderScope(child: CompanionApp()));
}
class CompanionApp extends ConsumerWidget {
const CompanionApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return MaterialApp.router(
title: 'localiserd Companion',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
routerConfig: ref.watch(routerProvider),
);
}
}
+132
View File
@@ -0,0 +1,132 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'domain/models/server_config.dart';
import 'domain/models/tag.dart';
import 'domain/models/particle.dart';
import 'domain/models/floor_plan_mode.dart';
import 'data/sources/local/credential_store.dart';
import 'data/sources/localiser/onboarding_client.dart';
import 'data/sources/localiser/session_client.dart';
import 'data/sources/localiser/floor_client.dart';
import 'data/sources/localiser/sensor_client.dart';
import 'data/sources/localiser/tag_client.dart';
import 'data/sources/localiser/realtime_data_client.dart';
import 'data/repositories/onboarding_repository.dart';
import 'data/repositories/sensor_repository.dart';
import 'data/repositories/tag_repository.dart';
import 'data/repositories/floor_plan_repository.dart';
import 'data/repositories/phoenix_onboarding_repository.dart';
import 'data/repositories/phoenix_sensor_repository.dart';
import 'data/repositories/phoenix_tag_repository.dart';
import 'data/repositories/phoenix_floor_plan_repository.dart';
// ---------------------------------------------------------------------------
// Connection / auth state — set imperatively after login
// ---------------------------------------------------------------------------
/// The server the user has selected. Null until a server is chosen.
final serverConfigProvider = StateProvider<ServerConfig?>((ref) => null);
/// JWT returned by /api/session or /api/setup. Null until authenticated.
final authTokenProvider = StateProvider<String?>((ref) => null);
/// Live WebSocket connection. Null until [RealtimeDataClient.connect] succeeds.
final realtimeDataClientProvider =
StateProvider<RealtimeDataClient?>((ref) => null);
// ---------------------------------------------------------------------------
// Convenience
// ---------------------------------------------------------------------------
final credentialStoreProvider = Provider<CredentialStore>((ref) {
return CredentialStore();
});
// ---------------------------------------------------------------------------
// Feature clients — throw if required state is missing
// ---------------------------------------------------------------------------
ServerConfig _requireConfig(Ref ref) {
final config = ref.watch(serverConfigProvider);
if (config == null) throw StateError('no server selected');
return config;
}
String _requireToken(Ref ref) {
final token = ref.watch(authTokenProvider);
if (token == null) throw StateError('not authenticated');
return token;
}
RealtimeDataClient _requireRealtime(Ref ref) {
final rt = ref.watch(realtimeDataClientProvider);
if (rt == null) throw StateError('realtime not connected');
return rt;
}
final onboardingClientProvider = Provider<OnboardingClient>((ref) {
return OnboardingClient(config: _requireConfig(ref));
});
final sessionClientProvider = Provider<SessionClient>((ref) {
return SessionClient(config: _requireConfig(ref));
});
final floorClientProvider = Provider<FloorClient>((ref) {
return FloorClient(config: _requireConfig(ref), token: _requireToken(ref));
});
final sensorClientProvider = Provider<SensorClient>((ref) {
return SensorClient(config: _requireConfig(ref), token: _requireToken(ref));
});
final tagClientProvider = Provider<TagClient>((ref) {
return TagClient(config: _requireConfig(ref), token: _requireToken(ref));
});
// ---------------------------------------------------------------------------
// Repositories
// ---------------------------------------------------------------------------
final onboardingRepositoryProvider = Provider<OnboardingRepository>((ref) {
return PhoenixOnboardingRepository(
client: ref.watch(onboardingClientProvider));
});
final sensorRepositoryProvider = Provider<SensorRepository>((ref) {
return PhoenixSensorRepository(client: ref.watch(sensorClientProvider));
});
final tagRepositoryProvider = Provider<TagRepository>((ref) {
return PhoenixTagRepository(
tagClient: ref.watch(tagClientProvider),
realtime: _requireRealtime(ref),
);
});
final floorPlanRepositoryProvider = Provider<FloorPlanRepository>((ref) {
return PhoenixFloorPlanRepository(client: ref.watch(floorClientProvider));
});
// ---------------------------------------------------------------------------
// Cross-tab UI state
// ---------------------------------------------------------------------------
final selectedSensorIdProvider = StateProvider<String?>((ref) => null);
final floorPlanModeProvider =
StateProvider<FloorPlanMode>((ref) => FloorPlanMode.view);
// ---------------------------------------------------------------------------
// Live data streams
// ---------------------------------------------------------------------------
final tagPositionsProvider = StreamProvider<List<TagPosition>>((ref) {
final repo = ref.watch(tagRepositoryProvider);
return repo.watchPositions();
});
final particleCloudProvider = StreamProvider<List<Particle>>((ref) {
final repo = ref.watch(tagRepositoryProvider);
return repo.watchParticleCloud();
});
+92
View File
@@ -0,0 +1,92 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'features/connection/server_discovery_screen.dart';
import 'features/connection/login_screen.dart';
import 'features/onboarding/onboarding_screen.dart';
import 'features/shell/main_shell.dart';
import 'features/floorplan/floor_plan_screen.dart';
import 'features/sensors/sensor_list_screen.dart';
import 'features/sensors/sensor_detail_screen.dart';
import 'features/tags/tag_list_screen.dart';
import 'features/tags/tag_detail_screen.dart';
import 'features/settings/settings_screen.dart';
import 'providers.dart';
const _unauthenticated = {'/connect', '/login', '/onboarding'};
final routerProvider = Provider<GoRouter>((ref) {
return GoRouter(
initialLocation: '/connect',
redirect: (context, state) {
final hasConfig = ref.read(serverConfigProvider) != null;
final hasToken = ref.read(authTokenProvider) != null;
final loc = state.matchedLocation;
if (!hasConfig && loc != '/connect') return '/connect';
if (hasConfig && !hasToken && !_unauthenticated.contains(loc)) {
return '/login';
}
return null;
},
routes: [
GoRoute(
path: '/connect',
builder: (context, state) => const ServerDiscoveryScreen(),
),
GoRoute(
path: '/login',
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: '/onboarding',
builder: (context, state) => const OnboardingScreen(),
),
StatefulShellRoute.indexedStack(
builder: (context, state, shell) => MainShell(shell: shell),
branches: [
StatefulShellBranch(routes: [
GoRoute(
path: '/floorplan',
builder: (context, state) => const FloorPlanScreen(),
),
]),
StatefulShellBranch(routes: [
GoRoute(
path: '/sensors',
builder: (context, state) => const SensorListScreen(),
routes: [
GoRoute(
path: ':id',
builder: (context, state) => SensorDetailScreen(
sensorId: state.pathParameters['id']!,
),
),
],
),
]),
StatefulShellBranch(routes: [
GoRoute(
path: '/tags',
builder: (context, state) => const TagListScreen(),
routes: [
GoRoute(
path: ':id',
builder: (context, state) => TagDetailScreen(
tagId: state.pathParameters['id']!,
),
),
],
),
]),
StatefulShellBranch(routes: [
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsScreen(),
),
]),
],
),
],
);
});