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
+45
View File
@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
+30
View File
@@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "02085feb3f5d8a8156e5e28512b9d99351d510c0"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0
base_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0
- platform: android
create_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0
base_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+17
View File
@@ -0,0 +1,17 @@
# companion
Companion app for localiserd
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
+28
View File
@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+14
View File
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
+44
View File
@@ -0,0 +1,44 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.localiserd.companion"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.localiserd.companion"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+61
View File
@@ -0,0 +1,61 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Network (Phoenix WebSocket + mDNS) -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<!-- BLE — legacy flags for API ≤ 30 -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
<!-- BLE — API 31+ -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<application
android:label="companion"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package com.localiserd.companion
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+24
View File
@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+2
View File
@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
+5
View File
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")
+194
View File
@@ -0,0 +1,194 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #111827; overflow: hidden; }
#container { width: 100vw; height: 100vh; }
</style>
</head>
<body>
<div id="container"></div>
<!--
For production: download konva.min.js and place it alongside this file
so it loads offline. Get it from: https://konvajs.org/
-->
<script src="https://unpkg.com/konva@9/konva.min.js"></script>
<script>
const stage = new Konva.Stage({
container: 'container',
width: window.innerWidth,
height: window.innerHeight,
});
// Layer order (bottom → top)
const roomsLayer = new Konva.Layer(); // static floor plan geometry
const sensorsLayer = new Konva.Layer(); // sensor icons
const tagsLayer = new Konva.Layer(); // live tag dots
const particlesLayer = new Konva.Layer(); // particle cloud
stage.add(roomsLayer, sensorsLayer, tagsLayer, particlesLayer);
window.addEventListener('resize', () => {
stage.width(window.innerWidth);
stage.height(window.innerHeight);
stage.batchDraw();
});
// -----------------------------------------------------------------------
// companion API — called by Flutter via WebViewController.runJavaScript()
// -----------------------------------------------------------------------
window.companion = {
// positions: [{tagId, x, y}] (x, y normalised 0..1)
updateTags(payload) {
const positions = JSON.parse(payload);
tagsLayer.destroyChildren();
positions.forEach(({ tagId, x, y }) => {
const dot = new Konva.Circle({
x: x * stage.width(),
y: y * stage.height(),
radius: 8,
fill: '#00d4ff',
stroke: '#ffffff',
strokeWidth: 1.5,
});
tagsLayer.add(dot);
});
tagsLayer.batchDraw();
},
// particles: [{x, y, weight}] (x, y normalised 0..1, weight ≥ 0)
updateCloud(payload) {
const particles = JSON.parse(payload);
particlesLayer.destroyChildren();
particles.forEach(({ x, y, weight }) => {
particlesLayer.add(new Konva.Circle({
x: x * stage.width(),
y: y * stage.height(),
radius: 2,
fill: `rgba(255, 180, 0, ${Math.min(weight, 1)})`,
listening: false,
}));
});
particlesLayer.batchDraw();
},
// mode: 'view' | 'edit'
setMode(mode) {
sensorsLayer.find('Group').forEach(group => {
group.draggable(mode === 'edit');
});
particlesLayer.visible(mode !== 'edit');
stage.batchDraw();
},
// Pan/zoom to a sensor and add a highlight ring. Pass null to clear.
highlightSensor(sensorId) {
sensorsLayer.find('.highlight').forEach(n => n.destroy());
if (!sensorId) { sensorsLayer.batchDraw(); return; }
const group = sensorsLayer.findOne(`#${sensorId}`);
if (!group) return;
const ring = new Konva.Circle({
x: group.x(),
y: group.y(),
radius: 22,
stroke: '#facc15',
strokeWidth: 3,
name: 'highlight',
listening: false,
});
sensorsLayer.add(ring);
sensorsLayer.batchDraw();
// TODO: animate stage position to centre on sensor.
},
// plan: the JSON representation of a FloorPlan (rooms with polygon vertices)
loadFloorPlan(payload) {
const plan = JSON.parse(payload);
roomsLayer.destroyChildren();
(plan.rooms || []).forEach(room => {
if (!room.polygon || room.polygon.length < 2) return;
const points = room.polygon.flatMap(p => [
p.x * stage.width(),
p.y * stage.height(),
]);
const poly = new Konva.Line({
points,
fill: '#1e293b',
stroke: '#475569',
strokeWidth: 2,
closed: true,
});
const label = new Konva.Text({
x: points[0],
y: points[1],
text: room.name,
fill: '#94a3b8',
fontSize: 12,
listening: false,
});
roomsLayer.add(poly, label);
});
roomsLayer.batchDraw();
},
// sensors: [{id, name, x, y}]
loadSensors(payload) {
const sensors = JSON.parse(payload);
sensorsLayer.destroyChildren();
sensors.forEach(({ id, name, x, y }) => {
const px = x * stage.width();
const py = y * stage.height();
const group = new Konva.Group({ id, x: px, y: py, draggable: false });
group.add(new Konva.Circle({
radius: 14,
fill: '#4f46e5',
stroke: '#818cf8',
strokeWidth: 2,
}));
group.add(new Konva.Text({
text: name,
fontSize: 10,
fill: '#e2e8f0',
offsetX: 20,
y: 18,
width: 40,
align: 'center',
listening: false,
}));
group.on('click tap', () => {
notifyFlutter('sensorTapped', { id });
});
group.on('dragend', () => {
notifyFlutter('sensorMoved', {
id,
x: group.x() / stage.width(),
y: group.y() / stage.height(),
});
});
sensorsLayer.add(group);
});
sensorsLayer.batchDraw();
},
};
// -----------------------------------------------------------------------
// Dart ← JS bridge
// -----------------------------------------------------------------------
function notifyFlutter(type, payload) {
if (window.FlutterBridge) {
FlutterBridge.postMessage(JSON.stringify({ type, ...payload }));
}
}
</script>
</body>
</html>
@@ -0,0 +1,6 @@
import '../../domain/models/floor_plan.dart';
abstract class FloorPlanRepository {
Future<FloorPlan?> getFloorPlan();
Future<FloorPlan> saveFloorPlan(FloorPlan plan);
}
@@ -0,0 +1,7 @@
import '../../domain/models/onboarding_status.dart';
abstract class OnboardingRepository {
Future<OnboardingStatus> getStatus();
/// Creates the first admin user and returns the issued JWT.
Future<String> createAdminUser({required String username, required String password});
}
@@ -0,0 +1,15 @@
import '../../domain/models/floor_plan.dart';
import '../sources/localiser/floor_client.dart';
import 'floor_plan_repository.dart';
class PhoenixFloorPlanRepository implements FloorPlanRepository {
const PhoenixFloorPlanRepository({required this.client});
final FloorClient client;
@override
Future<FloorPlan?> getFloorPlan() => throw UnimplementedError();
@override
Future<FloorPlan> saveFloorPlan(FloorPlan plan) => throw UnimplementedError();
}
@@ -0,0 +1,27 @@
import '../../domain/models/onboarding_status.dart';
import '../sources/localiser/onboarding_client.dart';
import 'onboarding_repository.dart';
class PhoenixOnboardingRepository implements OnboardingRepository {
const PhoenixOnboardingRepository({required this.client});
final OnboardingClient client;
@override
Future<OnboardingStatus> getStatus() async {
final checklist = await client.getChecklist();
if (!checklist.hasAdmin) return OnboardingStatus.notStarted;
if (!checklist.hasFloors) return OnboardingStatus.awaitingFloorPlan;
if (!checklist.hasSensorsPlaced) return OnboardingStatus.awaitingFirstSensor;
return OnboardingStatus.complete;
}
@override
Future<String> createAdminUser({
required String username,
required String password,
}) async {
final response = await client.setup(username, password);
return response.token;
}
}
@@ -0,0 +1,27 @@
import '../../domain/models/sensor.dart';
import '../../domain/models/position.dart';
import '../sources/localiser/sensor_client.dart';
import 'sensor_repository.dart';
class PhoenixSensorRepository implements SensorRepository {
const PhoenixSensorRepository({required this.client});
final SensorClient client;
@override
Future<List<Sensor>> getSensors() => throw UnimplementedError();
@override
Future<Sensor> getSensor(String id) => throw UnimplementedError();
@override
Future<Sensor> createSensor({required String name, required Position position}) =>
throw UnimplementedError();
@override
Future<Sensor> updateSensor(String id, {String? name, Position? position}) =>
throw UnimplementedError();
@override
Future<void> deleteSensor(String id) => throw UnimplementedError();
}
@@ -0,0 +1,37 @@
import '../../domain/models/tag.dart';
import '../../domain/models/particle.dart';
import '../sources/localiser/tag_client.dart';
import '../sources/localiser/realtime_data_client.dart';
import 'tag_repository.dart';
class PhoenixTagRepository implements TagRepository {
const PhoenixTagRepository({
required this.tagClient,
required this.realtime,
});
final TagClient tagClient;
final RealtimeDataClient realtime;
@override
Future<List<Tag>> getTags() => throw UnimplementedError();
@override
Future<Tag> getTag(String id) => throw UnimplementedError();
@override
Future<Tag> createTag({required String id, required String name}) =>
throw UnimplementedError();
@override
Future<Tag> updateTag(String id, {String? name}) => throw UnimplementedError();
@override
Future<void> deleteTag(String id) => throw UnimplementedError();
@override
Stream<List<TagPosition>> watchPositions() => throw UnimplementedError();
@override
Stream<List<Particle>> watchParticleCloud() => throw UnimplementedError();
}
@@ -0,0 +1,10 @@
import '../../domain/models/sensor.dart';
import '../../domain/models/position.dart';
abstract class SensorRepository {
Future<List<Sensor>> getSensors();
Future<Sensor> getSensor(String id);
Future<Sensor> createSensor({required String name, required Position position});
Future<Sensor> updateSensor(String id, {String? name, Position? position});
Future<void> deleteSensor(String id);
}
+16
View File
@@ -0,0 +1,16 @@
import '../../domain/models/tag.dart';
import '../../domain/models/particle.dart';
abstract class TagRepository {
Future<List<Tag>> getTags();
Future<Tag> getTag(String id);
Future<Tag> createTag({required String id, required String name});
Future<Tag> updateTag(String id, {String? name});
Future<void> deleteTag(String id);
/// Live stream of all tag positions, pushed by localiserd over Phoenix channel.
Stream<List<TagPosition>> watchPositions();
/// Live stream of particle filter cloud snapshots.
Stream<List<Particle>> watchParticleCloud();
}
+37
View File
@@ -0,0 +1,37 @@
// Uses flutter_blue_plus to drive the ESP-IDF Unified Provisioning GATT protocol.
// No BLE pairing or password required.
//
// Android: minSdkVersion must be ≥ 21 in android/app/build.gradle.
class BleScanResult {
const BleScanResult({
required this.deviceId,
required this.name,
required this.rssi,
});
final String deviceId;
final String name;
final int rssi;
}
class BleProvisioner {
// TODO: implement using flutter_blue_plus.
// Filter scan by the ESP-IDF provisioning service UUID advertised by your firmware.
/// Starts a BLE scan and emits discovered ESP32 provisioning devices.
Stream<BleScanResult> scan() => throw UnimplementedError();
Future<void> stopScan() async => throw UnimplementedError();
/// Connects to [deviceId] and sends WiFi credentials via the ESP-IDF
/// Unified Provisioning GATT profile (protobuf over BLE characteristic).
Future<void> provision(
String deviceId, {
required String ssid,
required String wifiPassword,
}) async =>
throw UnimplementedError();
void dispose() {}
}
@@ -0,0 +1,31 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
typedef Credentials = ({String username, String password});
class CredentialStore {
static const _usernameKey = 'localiserd_username';
static const _passwordKey = 'localiserd_password';
final _storage = const FlutterSecureStorage();
Future<void> save(Credentials credentials) => Future.wait([
_storage.write(key: _usernameKey, value: credentials.username),
_storage.write(key: _passwordKey, value: credentials.password),
]).then((_) {});
Future<Credentials?> load() async {
final results = await Future.wait([
_storage.read(key: _usernameKey),
_storage.read(key: _passwordKey),
]);
final username = results[0];
final password = results[1];
if (username == null || password == null) return null;
return (username: username, password: password);
}
Future<void> clear() => Future.wait([
_storage.delete(key: _usernameKey),
_storage.delete(key: _passwordKey),
]).then((_) {});
}
@@ -0,0 +1,36 @@
import '../../../domain/models/server_config.dart';
import 'localiser_client.dart';
class FloorClient extends LocaliserdClient {
FloorClient({required super.config, required String super.token});
Future<List<dynamic>> getFloors() async =>
await get('/api/floors') as List<dynamic>;
Future<Map<String, dynamic>> getFloor(int id) async =>
await get('/api/floors/$id') as Map<String, dynamic>;
Future<Map<String, dynamic>> createFloor(Map<String, dynamic> params) async =>
await post('/api/floors', params) as Map<String, dynamic>;
Future<Map<String, dynamic>> updateFloor(
int id, Map<String, dynamic> params) async =>
await patch('/api/floors/$id', params) as Map<String, dynamic>;
Future<void> deleteFloor(int id) => delete('/api/floors/$id');
Future<List<dynamic>> getRooms(int floorId) async =>
await get('/api/floors/$floorId/rooms') as List<dynamic>;
Future<Map<String, dynamic>> createRoom(
int floorId, Map<String, dynamic> params) async =>
await post('/api/floors/$floorId/rooms', params) as Map<String, dynamic>;
Future<Map<String, dynamic>> updateRoom(
int floorId, int id, Map<String, dynamic> params) async =>
await patch('/api/floors/$floorId/rooms/$id', params)
as Map<String, dynamic>;
Future<void> deleteRoom(int floorId, int id) =>
delete('/api/floors/$floorId/rooms/$id');
}
@@ -0,0 +1,68 @@
import 'dart:convert';
import 'dart:io';
import '../../../domain/models/server_config.dart';
class ApiException implements Exception {
const ApiException(this.statusCode, this.message);
final int statusCode;
final String message;
@override
String toString() => 'ApiException $statusCode: $message';
}
abstract class LocaliserdClient {
const LocaliserdClient({required this.config, this.token});
final ServerConfig config;
/// Null for unauthenticated clients (session, onboarding setup).
final String? token;
Uri _uri(String path) => Uri(
scheme: 'http',
host: config.host,
port: config.port,
path: path,
);
Future<dynamic> get(String path) => _request('GET', path);
Future<dynamic> post(String path, [Object? body]) => _request('POST', path, body);
Future<dynamic> put(String path, [Object? body]) => _request('PUT', path, body);
Future<dynamic> patch(String path, [Object? body]) => _request('PATCH', path, body);
Future<void> delete(String path) => _request('DELETE', path).then((_) {});
/// Like [delete] but returns the response body (for endpoints that do so).
Future<dynamic> deleteBody(String path) => _request('DELETE', path);
Future<dynamic> _request(String method, String path, [Object? body]) async {
final http = HttpClient();
try {
final request = await http.openUrl(method, _uri(path));
request.headers.contentType = ContentType.json;
if (token != null) {
request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $token');
}
if (body != null) request.write(jsonEncode(body));
final response = await request.close();
if (response.statusCode == 204) return null;
final responseBody = await response.transform(utf8.decoder).join();
if (response.statusCode >= 400) {
final parsed = responseBody.isNotEmpty
? jsonDecode(responseBody) as Map<String, dynamic>?
: null;
throw ApiException(
response.statusCode,
parsed?['error']?.toString() ?? responseBody,
);
}
return responseBody.isEmpty ? null : jsonDecode(responseBody);
} finally {
http.close();
}
}
}
@@ -0,0 +1,47 @@
import '../../../domain/models/auth.dart';
import '../../../domain/models/server_config.dart';
import 'localiser_client.dart';
class OnboardingChecklist {
const OnboardingChecklist({
required this.hasAdmin,
required this.hasFloors,
required this.hasRooms,
required this.hasSensorsPlaced,
required this.hasTags,
});
final bool hasAdmin;
final bool hasFloors;
final bool hasRooms;
final bool hasSensorsPlaced;
final bool hasTags;
factory OnboardingChecklist.fromJson(Map<String, dynamic> json) =>
OnboardingChecklist(
hasAdmin: json['has_admin'] as bool,
hasFloors: json['has_floors'] as bool,
hasRooms: json['has_rooms'] as bool,
hasSensorsPlaced: json['has_sensors_placed'] as bool,
hasTags: json['has_tags'] as bool,
);
}
class OnboardingClient extends LocaliserdClient {
OnboardingClient({required super.config});
Future<OnboardingChecklist> getChecklist() async {
final json = await get('/api/onboarding') as Map<String, dynamic>;
return OnboardingChecklist.fromJson(json);
}
/// Creates the first admin user. Only succeeds when no users exist yet.
/// Returns the JWT so the caller can immediately authenticate.
Future<TokenResponse> setup(String username, String password) async {
final json = await post('/api/setup', {
'username': username,
'password': password,
}) as Map<String, dynamic>;
return TokenResponse.fromJson(json);
}
}
@@ -0,0 +1,72 @@
import 'package:phoenix_socket/phoenix_socket.dart';
import '../../../domain/models/server_config.dart';
class RealtimeDataClient {
RealtimeDataClient({required this.config, required this.token});
final ServerConfig config;
final String token;
PhoenixSocket? _socket;
final _channels = <String, PhoenixChannel>{};
Future<void> connect() async {
_socket = PhoenixSocket(
config.wsUrl,
socketOptions: PhoenixSocketOptions(
params: {'token': token},
),
);
await _socket!.connect();
}
Future<void> disconnect() async {
for (final channel in _channels.values) {
channel.leave();
}
_channels.clear();
_socket?.close();
_socket = null;
}
bool get isConnected => _socket?.isConnected ?? false;
/// Joins [topic] (if not already joined) and returns a stream of message
/// payloads for that topic. The stream stays open until [disconnect] is
/// called or the underlying socket closes.
Stream<Map<String, dynamic>> channel(
String topic, {
Map<String, dynamic> params = const {},
}) {
final socket = _socket;
if (socket == null) throw StateError('RealtimeDataClient not connected');
final channel = _channels.putIfAbsent(
topic,
() {
final ch = socket.addChannel(topic: topic, parameters: params);
ch.join();
return ch;
},
);
return channel.messages
.where((msg) => msg.event.value != 'phx_reply')
.map((msg) => msg.payload ?? const {});
}
/// Pushes [event] on [topic] and waits for the server reply.
/// The channel must have been joined first via [channel].
Future<Map<String, dynamic>> push(
String topic,
String event,
Map<String, dynamic> payload,
) async {
final ch = _channels[topic];
if (ch == null) throw StateError('Channel $topic has not been joined');
final reply = await ch.push(event, payload).future;
return (reply.response as Map<String, dynamic>?) ?? const {};
}
}
@@ -0,0 +1,37 @@
import '../../../domain/models/server_config.dart';
import 'localiser_client.dart';
class SensorClient extends LocaliserdClient {
SensorClient({required ServerConfig config, required String token})
: super(config: config, token: token);
Future<List<dynamic>> getSensors() async =>
await get('/api/sensors') as List<dynamic>;
Future<List<dynamic>> getUnplacedSensors() async =>
await get('/api/sensors/unplaced') as List<dynamic>;
Future<Map<String, dynamic>> getSensor(int id) async =>
await get('/api/sensors/$id') as Map<String, dynamic>;
Future<Map<String, dynamic>> updateSensor(
int id, Map<String, dynamic> params) async =>
await put('/api/sensors/$id', params) as Map<String, dynamic>;
Future<void> deleteSensor(int id) => delete('/api/sensors/$id');
Future<Map<String, dynamic>> placeSensor(
int id, Map<String, dynamic> params) async =>
await put('/api/sensors/$id/place', params) as Map<String, dynamic>;
Future<Map<String, dynamic>> unplaceSensor(int id) async =>
await deleteBody('/api/sensors/$id/place') as Map<String, dynamic>;
Future<Map<String, dynamic>> startCalibration(
int id, double referenceDistance) async =>
await post('/api/sensors/$id/calibration/start',
{'reference_distance': referenceDistance}) as Map<String, dynamic>;
Future<Map<String, dynamic>> stopCalibration(int id) async =>
await post('/api/sensors/$id/calibration/stop') as Map<String, dynamic>;
}
@@ -0,0 +1,15 @@
import '../../../domain/models/auth.dart';
import '../../../domain/models/server_config.dart';
import 'localiser_client.dart';
class SessionClient extends LocaliserdClient {
SessionClient({required super.config});
Future<TokenResponse> login(String username, String password) async {
final json = await post('/api/session', {
'username': username,
'password': password,
}) as Map<String, dynamic>;
return TokenResponse.fromJson(json);
}
}
@@ -0,0 +1,21 @@
import '../../../domain/models/server_config.dart';
import 'localiser_client.dart';
class TagClient extends LocaliserdClient {
TagClient({required super.config, required String super.token});
Future<List<dynamic>> getTags() async =>
await get('/api/tags') as List<dynamic>;
Future<Map<String, dynamic>> getTag(int id) async =>
await get('/api/tags/$id') as Map<String, dynamic>;
Future<Map<String, dynamic>> createTag(Map<String, dynamic> params) async =>
await post('/api/tags', params) as Map<String, dynamic>;
Future<Map<String, dynamic>> updateTag(
int id, Map<String, dynamic> params) async =>
await patch('/api/tags/$id', params) as Map<String, dynamic>;
Future<void> deleteTag(int id) => delete('/api/tags/$id');
}
+41
View File
@@ -0,0 +1,41 @@
import 'dart:async';
import 'package:multicast_dns/multicast_dns.dart';
import '../../../domain/models/server_config.dart';
class MdnsDiscovery {
static const String serviceType = '_localiserd._tcp';
MDnsClient? _client;
Stream<ServerConfig> discover() async* {
_client?.stop();
final client = MDnsClient();
_client = client;
await client.start();
try {
await for (final ptr in client.lookup<PtrResourceRecord>(
ResourceRecordQuery.serverPointer(serviceType),
)) {
await for (final srv in client.lookup<SrvResourceRecord>(
ResourceRecordQuery.service(ptr.domainName),
)) {
await for (final ip in client.lookup<IPAddressResourceRecord>(
ResourceRecordQuery.addressIPv4(srv.target),
)) {
yield ServerConfig(host: ip.address.address, port: srv.port);
}
}
}
} finally {
client.stop();
if (_client == client) _client = null;
}
}
Future<void> stop() async {
_client?.stop();
_client = null;
}
}
+25
View File
@@ -0,0 +1,25 @@
class User {
const User({required this.id, required this.username, required this.isAdmin});
final int id;
final String username;
final bool isAdmin;
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'] as int,
username: json['username'] as String,
isAdmin: json['is_admin'] as bool,
);
}
class TokenResponse {
const TokenResponse({required this.token, required this.user});
final String token;
final User user;
factory TokenResponse.fromJson(Map<String, dynamic> json) => TokenResponse(
token: json['token'] as String,
user: User.fromJson(json['user'] as Map<String, dynamic>),
);
}
+68
View File
@@ -0,0 +1,68 @@
import 'position.dart';
class Room {
const Room({required this.id, required this.name, required this.polygon});
final String id;
final String name;
/// Polygon vertices in normalised 0..1 coordinates.
final List<Position> polygon;
Room copyWith({String? name, List<Position>? polygon}) =>
Room(id: id, name: name ?? this.name, polygon: polygon ?? this.polygon);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'polygon': polygon.map((p) => p.toJson()).toList(),
};
factory Room.fromJson(Map<String, dynamic> json) => Room(
id: json['id'] as String,
name: json['name'] as String,
polygon: (json['polygon'] as List)
.map((p) => Position.fromJson(p as Map<String, dynamic>))
.toList(),
);
}
class FloorPlan {
const FloorPlan({
required this.id,
required this.floorId,
required this.metersPerUnit,
required this.rooms,
});
final String id;
final String floorId;
/// Scale: how many real-world meters one normalised unit represents.
final double metersPerUnit;
final List<Room> rooms;
FloorPlan copyWith({double? metersPerUnit, List<Room>? rooms}) => FloorPlan(
id: id,
floorId: floorId,
metersPerUnit: metersPerUnit ?? this.metersPerUnit,
rooms: rooms ?? this.rooms,
);
Map<String, dynamic> toJson() => {
'id': id,
'floor_id': floorId,
'meters_per_unit': metersPerUnit,
'rooms': rooms.map((r) => r.toJson()).toList(),
};
factory FloorPlan.fromJson(Map<String, dynamic> json) => FloorPlan(
id: json['id'] as String,
floorId: json['floor_id'] as String,
metersPerUnit: (json['meters_per_unit'] as num).toDouble(),
rooms: (json['rooms'] as List)
.map((r) => Room.fromJson(r as Map<String, dynamic>))
.toList(),
);
}
+1
View File
@@ -0,0 +1 @@
enum FloorPlanMode { view, edit }
+13
View File
@@ -0,0 +1,13 @@
enum OnboardingStatus {
/// No admin user exists yet.
notStarted,
/// Admin user created, floor plan not yet saved.
awaitingFloorPlan,
/// Floor plan saved, no sensors enrolled yet.
awaitingFirstSensor,
/// At least one sensor enrolled; onboarding considered complete.
complete,
}
+17
View File
@@ -0,0 +1,17 @@
/// Single particle in a particle filter cloud snapshot.
class Particle {
const Particle({required this.x, required this.y, required this.weight});
/// Normalised 0..1 coordinates (same space as [Position]).
final double x;
final double y;
/// Unnormalised likelihood weight.
final double weight;
factory Particle.fromJson(Map<String, dynamic> json) => Particle(
x: (json['x'] as num).toDouble(),
y: (json['y'] as num).toDouble(),
weight: (json['weight'] as num).toDouble(),
);
}
+25
View File
@@ -0,0 +1,25 @@
// Normalised coordinates: x and y are in the range 0..1 relative to the floor plan canvas.
class Position {
const Position({required this.x, required this.y});
final double x;
final double y;
Position copyWith({double? x, double? y}) =>
Position(x: x ?? this.x, y: y ?? this.y);
// Key names should match localiserd API fields.
Map<String, dynamic> toJson() => {'x': x, 'y': y};
factory Position.fromJson(Map<String, dynamic> json) => Position(
x: (json['x'] as num).toDouble(),
y: (json['y'] as num).toDouble(),
);
@override
bool operator ==(Object other) =>
other is Position && other.x == x && other.y == y;
@override
int get hashCode => Object.hash(x, y);
}
+56
View File
@@ -0,0 +1,56 @@
import 'position.dart';
enum SensorStatus { online, offline, provisioning }
class Sensor {
const Sensor({
required this.id,
required this.name,
required this.floorId,
required this.position,
required this.status,
this.lastSeen,
});
final String id;
final String name;
final String floorId;
final Position position;
final SensorStatus status;
final DateTime? lastSeen;
Sensor copyWith({
String? name,
Position? position,
SensorStatus? status,
DateTime? lastSeen,
}) =>
Sensor(
id: id,
name: name ?? this.name,
floorId: floorId,
position: position ?? this.position,
status: status ?? this.status,
lastSeen: lastSeen ?? this.lastSeen,
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'floor_id': floorId,
'position': position.toJson(),
'status': status.name,
'last_seen': lastSeen?.toIso8601String(),
};
factory Sensor.fromJson(Map<String, dynamic> json) => Sensor(
id: json['id'] as String,
name: json['name'] as String,
floorId: json['floor_id'] as String,
position: Position.fromJson(json['position'] as Map<String, dynamic>),
status: SensorStatus.values.byName(json['status'] as String),
lastSeen: json['last_seen'] == null
? null
: DateTime.parse(json['last_seen'] as String),
);
}
+18
View File
@@ -0,0 +1,18 @@
class ServerConfig {
const ServerConfig({required this.host, required this.port});
final String host;
final int port;
String get wsUrl => 'ws://$host:$port/socket/websocket';
ServerConfig copyWith({String? host, int? port}) =>
ServerConfig(host: host ?? this.host, port: port ?? this.port);
@override
bool operator ==(Object other) =>
other is ServerConfig && other.host == host && other.port == port;
@override
int get hashCode => Object.hash(host, port);
}
+54
View File
@@ -0,0 +1,54 @@
import 'position.dart';
class Tag {
const Tag({
required this.id,
required this.name,
this.currentRoomId,
this.lastPosition,
this.lastSeen,
});
final String id;
final String name;
final String? currentRoomId;
final Position? lastPosition;
final DateTime? lastSeen;
Tag copyWith({String? name, String? currentRoomId, Position? lastPosition, DateTime? lastSeen}) =>
Tag(
id: id,
name: name ?? this.name,
currentRoomId: currentRoomId ?? this.currentRoomId,
lastPosition: lastPosition ?? this.lastPosition,
lastSeen: lastSeen ?? this.lastSeen,
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'current_room_id': currentRoomId,
'last_position': lastPosition?.toJson(),
'last_seen': lastSeen?.toIso8601String(),
};
factory Tag.fromJson(Map<String, dynamic> json) => Tag(
id: json['id'] as String,
name: json['name'] as String,
currentRoomId: json['current_room_id'] as String?,
lastPosition: json['last_position'] == null
? null
: Position.fromJson(json['last_position'] as Map<String, dynamic>),
lastSeen: json['last_seen'] == null
? null
: DateTime.parse(json['last_seen'] as String),
);
}
// Live position snapshot pushed from localiserd over Phoenix channel.
class TagPosition {
const TagPosition({required this.tagId, required this.position});
final String tagId;
final Position position;
}
@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/sources/ble/ble_provisioner.dart';
// Shared bottom sheet used by onboarding and the main sensor screens.
// Flow: scan select device enter WiFi credentials provision place on map.
class BleProvisionSheet extends ConsumerStatefulWidget {
const BleProvisionSheet({super.key});
@override
ConsumerState<BleProvisionSheet> createState() => _BleProvisionSheetState();
}
class _BleProvisionSheetState extends ConsumerState<BleProvisionSheet> {
final _provisioner = BleProvisioner();
final _ssidController = TextEditingController();
final _wifiPasswordController = TextEditingController();
BleScanResult? _selected;
bool _provisioning = false;
String? _error;
@override
void dispose() {
_provisioner.dispose();
_ssidController.dispose();
_wifiPasswordController.dispose();
super.dispose();
}
Future<void> _provision() async {
if (_selected == null) return;
setState(() {
_provisioning = true;
_error = null;
});
try {
await _provisioner.provision(
_selected!.deviceId,
ssid: _ssidController.text.trim(),
wifiPassword: _wifiPasswordController.text,
);
// TODO: poll localiserd until sensor appears, then prompt placement on map.
if (mounted) Navigator.of(context).pop();
} catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _provisioning = false);
}
}
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.6,
maxChildSize: 0.9,
builder: (context, scrollController) => Padding(
padding: EdgeInsets.fromLTRB(
24,
16,
24,
MediaQuery.of(context).viewInsets.bottom + 24,
),
child: ListView(
controller: scrollController,
children: [
Text('Add sensor',
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
// Scan results
// TODO: StreamBuilder on _provisioner.scan() show a list of
// BleScanResult tiles; tapping one sets _selected.
const Text('Nearby ESP32 devices'),
const SizedBox(height: 8),
const Placeholder(fallbackHeight: 120),
const SizedBox(height: 24),
if (_selected != null) ...[
Text('Selected: ${_selected!.name}'),
const SizedBox(height: 16),
TextField(
controller: _ssidController,
decoration: const InputDecoration(
labelText: 'WiFi SSID',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _wifiPasswordController,
decoration: const InputDecoration(
labelText: 'WiFi password',
border: OutlineInputBorder(),
),
obscureText: true,
),
const SizedBox(height: 16),
],
if (_error != null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(_error!,
style: TextStyle(
color: Theme.of(context).colorScheme.error)),
),
FilledButton(
onPressed: (_selected == null || _provisioning) ? null : _provision,
child: _provisioning
? const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Provision & add'),
),
],
),
),
);
}
}
+139
View File
@@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../data/sources/localiser/realtime_data_client.dart';
import '../../providers.dart';
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
bool _loading = false;
String? _error;
@override
void initState() {
super.initState();
_tryAutoLogin();
}
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _tryAutoLogin() async {
final store = ref.read(credentialStoreProvider);
final saved = await store.load();
if (saved == null || !mounted) return;
_usernameController.text = saved.username;
_passwordController.text = saved.password;
await _login(saved.username, saved.password, saveCredentials: false);
}
Future<void> _login(String username, String password,
{bool saveCredentials = true}) async {
setState(() {
_loading = true;
_error = null;
});
try {
final client = ref.read(sessionClientProvider);
final tokenResponse = await client.login(username, password);
final token = tokenResponse.token;
ref.read(authTokenProvider.notifier).state = token;
final config = ref.read(serverConfigProvider)!;
final realtime = RealtimeDataClient(config: config, token: token);
await realtime.connect();
ref.read(realtimeDataClientProvider.notifier).state = realtime;
if (saveCredentials) {
await ref
.read(credentialStoreProvider)
.save((username: username, password: password));
}
if (mounted) context.go('/floorplan');
} on Exception catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sign In')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: 'Username',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.username],
),
const SizedBox(height: 12),
TextField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Password',
border: OutlineInputBorder(),
),
obscureText: true,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.password],
onSubmitted: _loading
? null
: (_) => _login(
_usernameController.text.trim(),
_passwordController.text,
),
),
const SizedBox(height: 16),
if (_error != null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
FilledButton(
onPressed: _loading
? null
: () => _login(
_usernameController.text.trim(),
_passwordController.text,
),
child: _loading
? const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Sign in'),
),
],
),
),
);
}
}
@@ -0,0 +1,164 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../data/sources/localiser/onboarding_client.dart';
import '../../data/sources/mdns/mdns_discovery.dart';
import '../../domain/models/server_config.dart';
import '../../providers.dart';
class ServerDiscoveryScreen extends ConsumerStatefulWidget {
const ServerDiscoveryScreen({super.key});
@override
ConsumerState<ServerDiscoveryScreen> createState() =>
_ServerDiscoveryScreenState();
}
class _ServerDiscoveryScreenState extends ConsumerState<ServerDiscoveryScreen> {
final _hostController = TextEditingController();
final _portController = TextEditingController(text: '4000');
bool _connecting = false;
String? _error;
final _discovery = MdnsDiscovery();
final _discoveredServers = <ServerConfig>[];
StreamSubscription<ServerConfig>? _discoverySub;
@override
void initState() {
super.initState();
_discoverySub = _discovery.discover().listen(
(server) {
if (!_discoveredServers.contains(server)) {
setState(() => _discoveredServers.add(server));
}
},
onError: (_) {},
);
}
@override
void dispose() {
_discoverySub?.cancel();
_hostController.dispose();
_portController.dispose();
_discovery.stop();
super.dispose();
}
Future<void> _connect(ServerConfig config) async {
setState(() {
_connecting = true;
_error = null;
});
try {
final checklist =
await OnboardingClient(config: config).getChecklist();
ref.read(serverConfigProvider.notifier).state = config;
if (!mounted) return;
if (!checklist.hasAdmin) {
context.go('/onboarding');
} else {
context.go('/login');
}
} catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _connecting = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Connect to Server')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Discovered servers'),
const SizedBox(height: 8),
if (_discoveredServers.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(
child: Text(
'Scanning…',
style: TextStyle(color: Colors.grey),
),
),
)
else
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200),
child: ListView.builder(
shrinkWrap: true,
itemCount: _discoveredServers.length,
itemBuilder: (context, i) {
final server = _discoveredServers[i];
return ListTile(
title: Text(server.host),
subtitle: Text('Port ${server.port}'),
trailing: const Icon(Icons.chevron_right),
onTap: _connecting ? null : () => _connect(server),
);
},
),
),
const Divider(),
const SizedBox(height: 16),
const Text('Manual entry'),
const SizedBox(height: 8),
TextField(
controller: _hostController,
decoration: const InputDecoration(
labelText: 'Host / IP',
hintText: '192.168.1.100',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.url,
),
const SizedBox(height: 12),
TextField(
controller: _portController,
decoration: const InputDecoration(
labelText: 'Port',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
if (_error != null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
FilledButton(
onPressed: _connecting
? null
: () => _connect(ServerConfig(
host: _hostController.text.trim(),
port: int.tryParse(_portController.text) ?? 4000,
)),
child: _connecting
? const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Connect'),
),
],
),
),
);
}
}
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../domain/models/floor_plan_mode.dart';
import '../../providers.dart';
import '../ble_provision/ble_provision_sheet.dart';
import 'widgets/konva_web_view.dart';
class FloorPlanScreen extends ConsumerStatefulWidget {
const FloorPlanScreen({super.key});
@override
ConsumerState<FloorPlanScreen> createState() => _FloorPlanScreenState();
}
class _FloorPlanScreenState extends ConsumerState<FloorPlanScreen> {
final _konvaKey = GlobalKey<KonvaWebViewState>();
@override
Widget build(BuildContext context) {
final mode = ref.watch(floorPlanModeProvider);
// TODO: forward live tag positions into the WebView.
// ref.listen(tagPositionsProvider, (_, next) {
// next.whenData((positions) => _konvaKey.currentState?.updateTags(positions));
// });
// TODO: forward particle cloud updates into the WebView.
// ref.listen(particleCloudProvider, (_, next) {
// next.whenData((particles) => _konvaKey.currentState?.updateParticleCloud(particles));
// });
// TODO: react to selectedSensorIdProvider and highlight sensor in WebView.
// ref.listen(selectedSensorIdProvider, (_, id) {
// _konvaKey.currentState?.highlightSensor(id);
// });
return Scaffold(
appBar: AppBar(
title: const Text('Floor Plan'),
actions: [
IconButton(
tooltip: mode == FloorPlanMode.edit ? 'View mode' : 'Edit mode',
icon: Icon(
mode == FloorPlanMode.edit ? Icons.visibility : Icons.edit,
),
onPressed: () {
final next = mode == FloorPlanMode.edit
? FloorPlanMode.view
: FloorPlanMode.edit;
ref.read(floorPlanModeProvider.notifier).state = next;
_konvaKey.currentState?.setMode(next);
},
),
],
),
body: KonvaWebView(
key: _konvaKey,
mode: mode,
onSensorTapped: (id) {
ref.read(selectedSensorIdProvider.notifier).state = id;
// TODO: optionally navigate to sensor detail or show tooltip.
},
onSensorMoved: (id, position) {
// TODO: persist new position via sensorRepositoryProvider.
},
),
floatingActionButton: mode == FloorPlanMode.edit
? FloatingActionButton.extended(
icon: const Icon(Icons.add),
label: const Text('Add sensor'),
onPressed: () => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => const BleProvisionSheet(),
),
)
: null,
);
}
}
@@ -0,0 +1,82 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import '../../../domain/models/floor_plan_mode.dart';
import '../../../domain/models/tag.dart';
import '../../../domain/models/particle.dart';
import '../../../domain/models/position.dart';
class KonvaWebView extends StatefulWidget {
const KonvaWebView({
super.key,
required this.mode,
required this.onSensorTapped,
required this.onSensorMoved,
});
final FloorPlanMode mode;
final void Function(String sensorId) onSensorTapped;
final void Function(String sensorId, Position newPosition) onSensorMoved;
@override
State<KonvaWebView> createState() => KonvaWebViewState();
}
class KonvaWebViewState extends State<KonvaWebView> {
late final WebViewController _controller;
@override
void initState() {
super.initState();
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel('FlutterBridge', onMessageReceived: _onMessage)
..loadFlutterAsset('assets/konva/index.html');
}
void _onMessage(JavaScriptMessage message) {
final data = jsonDecode(message.message) as Map<String, dynamic>;
switch (data['type'] as String?) {
case 'sensorTapped':
widget.onSensorTapped(data['id'] as String);
case 'sensorMoved':
widget.onSensorMoved(
data['id'] as String,
Position(
x: (data['x'] as num).toDouble(),
y: (data['y'] as num).toDouble(),
),
);
}
}
Future<void> updateTags(List<TagPosition> positions) async {
final payload = jsonEncode(positions
.map((p) => {'tagId': p.tagId, 'x': p.position.x, 'y': p.position.y})
.toList());
await _controller.runJavaScript('window.companion.updateTags($payload)');
}
Future<void> updateParticleCloud(List<Particle> particles) async {
final payload = jsonEncode(particles
.map((p) => {'x': p.x, 'y': p.y, 'weight': p.weight})
.toList());
await _controller.runJavaScript('window.companion.updateCloud($payload)');
}
Future<void> highlightSensor(String? sensorId) async {
final id = sensorId == null ? 'null' : '"$sensorId"';
await _controller.runJavaScript('window.companion.highlightSensor($id)');
}
Future<void> setMode(FloorPlanMode mode) async {
await _controller.runJavaScript(
'window.companion.setMode("${mode.name}")',
);
}
@override
Widget build(BuildContext context) => WebViewWidget(controller: _controller);
}
@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'steps/step_admin_user.dart';
import 'steps/step_floor_plan.dart';
import 'steps/step_sensors.dart';
import 'steps/step_tags.dart';
import 'steps/step_done.dart';
class OnboardingScreen extends ConsumerStatefulWidget {
const OnboardingScreen({super.key});
@override
ConsumerState<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
int _step = 0;
void _advance() => setState(() => _step++);
@override
Widget build(BuildContext context) {
final steps = <Widget>[
StepAdminUser(onComplete: _advance),
StepFloorPlan(onComplete: _advance),
StepSensors(onComplete: _advance),
StepTags(onComplete: _advance),
StepDone(onComplete: () => context.go('/floorplan')),
];
return Scaffold(
appBar: AppBar(
title: const Text('Setup'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(4),
child: LinearProgressIndicator(
value: (_step + 1) / steps.length,
),
),
),
body: steps[_step],
);
}
}
@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../data/sources/localiser/realtime_data_client.dart';
import '../../../providers.dart';
class StepAdminUser extends ConsumerStatefulWidget {
const StepAdminUser({super.key, required this.onComplete});
final VoidCallback onComplete;
@override
ConsumerState<StepAdminUser> createState() => _StepAdminUserState();
}
class _StepAdminUserState extends ConsumerState<StepAdminUser> {
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
bool _loading = false;
String? _error;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _submit() async {
setState(() {
_loading = true;
_error = null;
});
try {
final username = _usernameController.text.trim();
final password = _passwordController.text;
final token = await ref.read(onboardingRepositoryProvider).createAdminUser(
username: username,
password: password,
);
ref.read(authTokenProvider.notifier).state = token;
final config = ref.read(serverConfigProvider)!;
final realtime = RealtimeDataClient(config: config, token: token);
await realtime.connect();
ref.read(realtimeDataClientProvider.notifier).state = realtime;
await ref
.read(credentialStoreProvider)
.save((username: username, password: password));
widget.onComplete();
} catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Create admin account',
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 24),
TextField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: 'Username',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Password',
border: OutlineInputBorder(),
),
obscureText: true,
),
const SizedBox(height: 16),
if (_error != null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(_error!,
style:
TextStyle(color: Theme.of(context).colorScheme.error)),
),
FilledButton(
onPressed: _loading ? null : _submit,
child: _loading
? const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Create account'),
),
],
),
);
}
}
@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
class StepDone extends StatelessWidget {
const StepDone({super.key, required this.onComplete});
final VoidCallback onComplete;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(Icons.check_circle_outline, size: 72),
const SizedBox(height: 24),
Text(
'Setup complete',
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
const Text(
'Your floor plan and sensors are configured. You can add more sensors and tags at any time from the main screen.',
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
FilledButton(
onPressed: onComplete,
child: const Text('Go to floor plan'),
),
],
),
);
}
}
@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
class StepFloorPlan extends StatelessWidget {
const StepFloorPlan({super.key, required this.onComplete});
final VoidCallback onComplete;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Draw floor plan',
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
// TODO: embed KonvaWebView in editor mode (no live overlays).
// User draws rooms, sets scale, then taps Continue.
const Expanded(child: Placeholder()),
const SizedBox(height: 16),
FilledButton(
onPressed: onComplete,
child: const Text('Continue'),
),
],
),
);
}
}
@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import '../../ble_provision/ble_provision_sheet.dart';
class StepSensors extends StatelessWidget {
const StepSensors({super.key, required this.onComplete});
final VoidCallback onComplete;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Enroll sensors', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
const Text('Add at least one sensor to continue.'),
const SizedBox(height: 16),
// TODO: list of already-enrolled sensors with placement status.
const Expanded(child: Placeholder()),
const SizedBox(height: 16),
OutlinedButton.icon(
icon: const Icon(Icons.bluetooth),
label: const Text('Add sensor'),
onPressed: () => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => const BleProvisionSheet(),
),
),
const SizedBox(height: 12),
FilledButton(
onPressed: onComplete,
child: const Text('Continue'),
),
],
),
);
}
}
@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
class StepTags extends StatelessWidget {
const StepTags({super.key, required this.onComplete});
final VoidCallback onComplete;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Enroll tags', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
const Text('Tags are optional here — you can enroll them later.'),
const SizedBox(height: 16),
// TODO: BLE scan list + enrolled tag list, similar to StepSensors.
const Expanded(child: Placeholder()),
const SizedBox(height: 16),
FilledButton(
onPressed: onComplete,
child: const Text('Continue'),
),
],
),
);
}
}
@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../providers.dart';
class SensorDetailScreen extends ConsumerWidget {
const SensorDetailScreen({super.key, required this.sensorId});
final String sensorId;
@override
Widget build(BuildContext context, WidgetRef ref) {
// TODO: fetch sensor via sensorRepositoryProvider.getSensor(sensorId).
return Scaffold(
appBar: AppBar(title: Text('Sensor $sensorId')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// TODO: display sensor fields (name, status, position, last seen).
const Placeholder(fallbackHeight: 200),
const SizedBox(height: 24),
OutlinedButton.icon(
icon: const Icon(Icons.map_outlined),
label: const Text('Locate on floor plan'),
onPressed: () {
ref.read(selectedSensorIdProvider.notifier).state = sensorId;
context.go('/floorplan');
},
),
const SizedBox(height: 12),
// TODO: re-provision button show BleProvisionSheet pre-filled.
OutlinedButton.icon(
icon: const Icon(Icons.bluetooth),
label: const Text('Re-provision WiFi'),
onPressed: () {}, // TODO
),
const SizedBox(height: 12),
OutlinedButton.icon(
icon: const Icon(Icons.edit_outlined),
label: const Text('Rename'),
onPressed: () {}, // TODO
),
const Spacer(),
TextButton(
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
onPressed: () {}, // TODO: confirm dialog then delete
child: const Text('Delete sensor'),
),
],
),
),
);
}
}
@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../providers.dart';
import '../ble_provision/ble_provision_sheet.dart';
class SensorListScreen extends ConsumerWidget {
const SensorListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// TODO: replace Placeholder with AsyncValue-driven list.
// final sensors = ref.watch(sensorsProvider); // define a FutureProvider
final selectedId = ref.watch(selectedSensorIdProvider);
return Scaffold(
appBar: AppBar(title: const Text('Sensors')),
body: Column(
children: [
if (selectedId != null)
MaterialBanner(
content: Text('Sensor $selectedId selected on floor plan'),
actions: [
TextButton(
onPressed: () =>
ref.read(selectedSensorIdProvider.notifier).state = null,
child: const Text('Dismiss'),
),
TextButton(
onPressed: () => context.push('/sensors/$selectedId'),
child: const Text('Open'),
),
],
),
// TODO: ListView.builder with sensor tiles.
// Highlight tile whose id == selectedId.
const Expanded(child: Placeholder()),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => const BleProvisionSheet(),
),
child: const Icon(Icons.add),
),
);
}
}
@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../providers.dart';
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final config = ref.watch(serverConfigProvider);
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: ListView(
children: [
ListTile(
title: const Text('Server'),
subtitle: config == null
? const Text('Not connected')
: Text('${config.host}:${config.port}'),
trailing: const Icon(Icons.chevron_right),
onTap: () {}, // TODO: show server config sheet
),
const Divider(),
// TODO: admin account section (change password).
const AboutListTile(applicationName: 'Companion'),
],
),
);
}
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class MainShell extends StatelessWidget {
const MainShell({super.key, required this.shell});
final StatefulNavigationShell shell;
@override
Widget build(BuildContext context) {
return Scaffold(
body: shell,
bottomNavigationBar: NavigationBar(
selectedIndex: shell.currentIndex,
onDestinationSelected: (index) => shell.goBranch(
index,
initialLocation: index == shell.currentIndex,
),
destinations: const [
NavigationDestination(
icon: Icon(Icons.map_outlined),
selectedIcon: Icon(Icons.map),
label: 'Floor Plan',
),
NavigationDestination(
icon: Icon(Icons.sensors_outlined),
selectedIcon: Icon(Icons.sensors),
label: 'Sensors',
),
NavigationDestination(
icon: Icon(Icons.label_outline),
selectedIcon: Icon(Icons.label),
label: 'Tags',
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Settings',
),
],
),
);
}
}
+41
View File
@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class TagDetailScreen extends ConsumerWidget {
const TagDetailScreen({super.key, required this.tagId});
final String tagId;
@override
Widget build(BuildContext context, WidgetRef ref) {
// TODO: fetch tag via tagRepositoryProvider.getTag(tagId).
return Scaffold(
appBar: AppBar(title: Text('Tag $tagId')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// TODO: display tag fields (name, current room, last seen, position).
const Placeholder(fallbackHeight: 200),
const Spacer(),
OutlinedButton.icon(
icon: const Icon(Icons.edit_outlined),
label: const Text('Rename'),
onPressed: () {}, // TODO
),
const SizedBox(height: 12),
TextButton(
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
onPressed: () {}, // TODO: confirm dialog then delete
child: const Text('Remove tag'),
),
],
),
),
);
}
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class TagListScreen extends ConsumerWidget {
const TagListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// TODO: replace Placeholder with AsyncValue-driven list.
// final tags = ref.watch(tagsProvider); // define a FutureProvider
return Scaffold(
appBar: AppBar(title: const Text('Tags')),
body: const Placeholder(),
floatingActionButton: FloatingActionButton(
onPressed: () {
// TODO: show tag enrollment sheet (BLE scan + manual ID fallback).
},
child: const Icon(Icons.add),
),
);
}
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'router.dart';
void main() {
runApp(const ProviderScope(child: CompanionApp()));
}
class CompanionApp extends ConsumerWidget {
const CompanionApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return MaterialApp.router(
title: 'localiserd Companion',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
routerConfig: ref.watch(routerProvider),
);
}
}
+132
View File
@@ -0,0 +1,132 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'domain/models/server_config.dart';
import 'domain/models/tag.dart';
import 'domain/models/particle.dart';
import 'domain/models/floor_plan_mode.dart';
import 'data/sources/local/credential_store.dart';
import 'data/sources/localiser/onboarding_client.dart';
import 'data/sources/localiser/session_client.dart';
import 'data/sources/localiser/floor_client.dart';
import 'data/sources/localiser/sensor_client.dart';
import 'data/sources/localiser/tag_client.dart';
import 'data/sources/localiser/realtime_data_client.dart';
import 'data/repositories/onboarding_repository.dart';
import 'data/repositories/sensor_repository.dart';
import 'data/repositories/tag_repository.dart';
import 'data/repositories/floor_plan_repository.dart';
import 'data/repositories/phoenix_onboarding_repository.dart';
import 'data/repositories/phoenix_sensor_repository.dart';
import 'data/repositories/phoenix_tag_repository.dart';
import 'data/repositories/phoenix_floor_plan_repository.dart';
// ---------------------------------------------------------------------------
// Connection / auth state set imperatively after login
// ---------------------------------------------------------------------------
/// The server the user has selected. Null until a server is chosen.
final serverConfigProvider = StateProvider<ServerConfig?>((ref) => null);
/// JWT returned by /api/session or /api/setup. Null until authenticated.
final authTokenProvider = StateProvider<String?>((ref) => null);
/// Live WebSocket connection. Null until [RealtimeDataClient.connect] succeeds.
final realtimeDataClientProvider =
StateProvider<RealtimeDataClient?>((ref) => null);
// ---------------------------------------------------------------------------
// Convenience
// ---------------------------------------------------------------------------
final credentialStoreProvider = Provider<CredentialStore>((ref) {
return CredentialStore();
});
// ---------------------------------------------------------------------------
// Feature clients throw if required state is missing
// ---------------------------------------------------------------------------
ServerConfig _requireConfig(Ref ref) {
final config = ref.watch(serverConfigProvider);
if (config == null) throw StateError('no server selected');
return config;
}
String _requireToken(Ref ref) {
final token = ref.watch(authTokenProvider);
if (token == null) throw StateError('not authenticated');
return token;
}
RealtimeDataClient _requireRealtime(Ref ref) {
final rt = ref.watch(realtimeDataClientProvider);
if (rt == null) throw StateError('realtime not connected');
return rt;
}
final onboardingClientProvider = Provider<OnboardingClient>((ref) {
return OnboardingClient(config: _requireConfig(ref));
});
final sessionClientProvider = Provider<SessionClient>((ref) {
return SessionClient(config: _requireConfig(ref));
});
final floorClientProvider = Provider<FloorClient>((ref) {
return FloorClient(config: _requireConfig(ref), token: _requireToken(ref));
});
final sensorClientProvider = Provider<SensorClient>((ref) {
return SensorClient(config: _requireConfig(ref), token: _requireToken(ref));
});
final tagClientProvider = Provider<TagClient>((ref) {
return TagClient(config: _requireConfig(ref), token: _requireToken(ref));
});
// ---------------------------------------------------------------------------
// Repositories
// ---------------------------------------------------------------------------
final onboardingRepositoryProvider = Provider<OnboardingRepository>((ref) {
return PhoenixOnboardingRepository(
client: ref.watch(onboardingClientProvider));
});
final sensorRepositoryProvider = Provider<SensorRepository>((ref) {
return PhoenixSensorRepository(client: ref.watch(sensorClientProvider));
});
final tagRepositoryProvider = Provider<TagRepository>((ref) {
return PhoenixTagRepository(
tagClient: ref.watch(tagClientProvider),
realtime: _requireRealtime(ref),
);
});
final floorPlanRepositoryProvider = Provider<FloorPlanRepository>((ref) {
return PhoenixFloorPlanRepository(client: ref.watch(floorClientProvider));
});
// ---------------------------------------------------------------------------
// Cross-tab UI state
// ---------------------------------------------------------------------------
final selectedSensorIdProvider = StateProvider<String?>((ref) => null);
final floorPlanModeProvider =
StateProvider<FloorPlanMode>((ref) => FloorPlanMode.view);
// ---------------------------------------------------------------------------
// Live data streams
// ---------------------------------------------------------------------------
final tagPositionsProvider = StreamProvider<List<TagPosition>>((ref) {
final repo = ref.watch(tagRepositoryProvider);
return repo.watchPositions();
});
final particleCloudProvider = StreamProvider<List<Particle>>((ref) {
final repo = ref.watch(tagRepositoryProvider);
return repo.watchParticleCloud();
});
+92
View File
@@ -0,0 +1,92 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'features/connection/server_discovery_screen.dart';
import 'features/connection/login_screen.dart';
import 'features/onboarding/onboarding_screen.dart';
import 'features/shell/main_shell.dart';
import 'features/floorplan/floor_plan_screen.dart';
import 'features/sensors/sensor_list_screen.dart';
import 'features/sensors/sensor_detail_screen.dart';
import 'features/tags/tag_list_screen.dart';
import 'features/tags/tag_detail_screen.dart';
import 'features/settings/settings_screen.dart';
import 'providers.dart';
const _unauthenticated = {'/connect', '/login', '/onboarding'};
final routerProvider = Provider<GoRouter>((ref) {
return GoRouter(
initialLocation: '/connect',
redirect: (context, state) {
final hasConfig = ref.read(serverConfigProvider) != null;
final hasToken = ref.read(authTokenProvider) != null;
final loc = state.matchedLocation;
if (!hasConfig && loc != '/connect') return '/connect';
if (hasConfig && !hasToken && !_unauthenticated.contains(loc)) {
return '/login';
}
return null;
},
routes: [
GoRoute(
path: '/connect',
builder: (context, state) => const ServerDiscoveryScreen(),
),
GoRoute(
path: '/login',
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: '/onboarding',
builder: (context, state) => const OnboardingScreen(),
),
StatefulShellRoute.indexedStack(
builder: (context, state, shell) => MainShell(shell: shell),
branches: [
StatefulShellBranch(routes: [
GoRoute(
path: '/floorplan',
builder: (context, state) => const FloorPlanScreen(),
),
]),
StatefulShellBranch(routes: [
GoRoute(
path: '/sensors',
builder: (context, state) => const SensorListScreen(),
routes: [
GoRoute(
path: ':id',
builder: (context, state) => SensorDetailScreen(
sensorId: state.pathParameters['id']!,
),
),
],
),
]),
StatefulShellBranch(routes: [
GoRoute(
path: '/tags',
builder: (context, state) => const TagListScreen(),
routes: [
GoRoute(
path: ':id',
builder: (context, state) => TagDetailScreen(
tagId: state.pathParameters['id']!,
),
),
],
),
]),
StatefulShellBranch(routes: [
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsScreen(),
),
]),
],
),
],
);
});
+690
View File
@@ -0,0 +1,690 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
bluez:
dependency: transitive
description:
name: bluez
sha256: "61a7204381925896a374301498f2f5399e59827c6498ae1e924aaa598751b545"
url: "https://pub.dev"
source: hosted
version: "0.8.3"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
dbus:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev"
source: hosted
version: "0.7.12"
equatable:
dependency: transitive
description:
name: equatable
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
url: "https://pub.dev"
source: hosted
version: "2.0.8"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_blue_plus:
dependency: "direct main"
description:
name: flutter_blue_plus
sha256: "69a8c87c11fc792e8cf0f997d275484fbdb5143ac9f0ac4d424429700cb4e0ed"
url: "https://pub.dev"
source: hosted
version: "1.36.8"
flutter_blue_plus_android:
dependency: transitive
description:
name: flutter_blue_plus_android
sha256: "6f7fe7e69659c30af164a53730707edc16aa4d959e4c208f547b893d940f853d"
url: "https://pub.dev"
source: hosted
version: "7.0.4"
flutter_blue_plus_darwin:
dependency: transitive
description:
name: flutter_blue_plus_darwin
sha256: "682982862c1d964f4d54a3fb5fccc9e59a066422b93b7e22079aeecd9c0d38f8"
url: "https://pub.dev"
source: hosted
version: "7.0.3"
flutter_blue_plus_linux:
dependency: transitive
description:
name: flutter_blue_plus_linux
sha256: "56b0c45edd0a2eec8f85bd97a26ac3cd09447e10d0094fed55587bf0592e3347"
url: "https://pub.dev"
source: hosted
version: "7.0.3"
flutter_blue_plus_platform_interface:
dependency: transitive
description:
name: flutter_blue_plus_platform_interface
sha256: "84fbd180c50a40c92482f273a92069960805ce324e3673ad29c41d2faaa7c5c2"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
flutter_blue_plus_web:
dependency: transitive
description:
name: flutter_blue_plus_web
sha256: a1aceee753d171d24c0e0cdadb37895b5e9124862721f25f60bb758e20b72c99
url: "https://pub.dev"
source: hosted
version: "7.0.2"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_riverpod:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
url: "https://pub.dev"
source: hosted
version: "9.2.4"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
url: "https://pub.dev"
source: hosted
version: "1.2.3"
flutter_secure_storage_macos:
dependency: transitive
description:
name: flutter_secure_storage_macos
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
url: "https://pub.dev"
source: hosted
version: "3.1.3"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
url: "https://pub.dev"
source: hosted
version: "1.1.2"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
url: "https://pub.dev"
source: hosted
version: "1.2.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
url: "https://pub.dev"
source: hosted
version: "3.1.2"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
go_router:
dependency: "direct main"
description:
name: go_router
sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3
url: "https://pub.dev"
source: hosted
version: "14.8.1"
hooks:
dependency: transitive
description:
name: hooks
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
jni:
dependency: transitive
description:
name: jni
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
url: "https://pub.dev"
source: hosted
version: "1.0.0"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
js:
dependency: transitive
description:
name: js
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
url: "https://pub.dev"
source: hosted
version: "0.6.7"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
multicast_dns:
dependency: "direct main"
description:
name: multicast_dns
sha256: de72ada5c3db6fdd6ad4ae99452fe05fb403c4bb37c67ceb255ddd37d2b5b1eb
url: "https://pub.dev"
source: hosted
version: "0.3.3"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
url: "https://pub.dev"
source: hosted
version: "0.17.6"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
pedantic:
dependency: transitive
description:
name: pedantic
sha256: "67fc27ed9639506c856c840ccce7594d0bdcd91bc8d53d6e52359449a1d50602"
url: "https://pub.dev"
source: hosted
version: "1.11.1"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
phoenix_socket:
dependency: "direct main"
description:
name: phoenix_socket
sha256: dcbf94b27a5492e14b7cc74dfd2e2a6cec73cfb09cee4f1cc19a58dfa40eb3c2
url: "https://pub.dev"
source: hosted
version: "0.6.4"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
quiver:
dependency: transitive
description:
name: quiver
sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2
url: "https://pub.dev"
source: hosted
version: "3.2.2"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
riverpod:
dependency: transitive
description:
name: riverpod
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb"
url: "https://pub.dev"
source: hosted
version: "0.27.7"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
state_notifier:
dependency: transitive
description:
name: state_notifier
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
url: "https://pub.dev"
source: hosted
version: "1.0.0"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev"
source: hosted
version: "0.7.10"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.2.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b
url: "https://pub.dev"
source: hosted
version: "2.4.0"
webview_flutter:
dependency: "direct main"
description:
name: webview_flutter
sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9
url: "https://pub.dev"
source: hosted
version: "4.13.1"
webview_flutter_android:
dependency: transitive
description:
name: webview_flutter_android
sha256: ad5182eff9a550925330cb9f0cb038eddfdd5712aba8b77aa0f0400e50f6e688
url: "https://pub.dev"
source: hosted
version: "4.12.0"
webview_flutter_platform_interface:
dependency: transitive
description:
name: webview_flutter_platform_interface
sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04"
url: "https://pub.dev"
source: hosted
version: "2.15.1"
webview_flutter_wkwebview:
dependency: transitive
description:
name: webview_flutter_wkwebview
sha256: "82648217f537573e1ca9ae9952d3eacedca6ab5aee69dc84445fc763766dcea2"
url: "https://pub.dev"
source: hosted
version: "3.25.1"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "5.15.0"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.11.5 <4.0.0"
flutter: ">=3.38.4"
+50
View File
@@ -0,0 +1,50 @@
name: companion
description: "Companion app for localiserd"
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
environment:
sdk: ^3.11.5
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.6.1
go_router: ^14.6.0
webview_flutter: ^4.10.0
flutter_blue_plus: ^1.35.0
multicast_dns: ^0.3.2
phoenix_socket: ^0.6.4
flutter_secure_storage: ^9.2.2
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter:
uses-material-design: true
assets:
- assets/konva/