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,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');
}