init: rough companion app stub
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>),
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
enum FloorPlanMode { view, edit }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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(),
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user