init: rough companion app stub
This commit is contained in:
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user