init: rough companion app stub
This commit is contained in:
+45
@@ -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
|
||||
@@ -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'
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import '../../domain/models/tag.dart';
|
||||
import '../../domain/models/particle.dart';
|
||||
|
||||
abstract class TagRepository {
|
||||
Future<List<Tag>> getTags();
|
||||
Future<Tag> getTag(String id);
|
||||
Future<Tag> createTag({required String id, required String name});
|
||||
Future<Tag> updateTag(String id, {String? name});
|
||||
Future<void> deleteTag(String id);
|
||||
|
||||
/// Live stream of all tag positions, pushed by localiserd over Phoenix channel.
|
||||
Stream<List<TagPosition>> watchPositions();
|
||||
|
||||
/// Live stream of particle filter cloud snapshots.
|
||||
Stream<List<Particle>> watchParticleCloud();
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Uses flutter_blue_plus to drive the ESP-IDF Unified Provisioning GATT protocol.
|
||||
// No BLE pairing or password required.
|
||||
//
|
||||
// Android: minSdkVersion must be ≥ 21 in android/app/build.gradle.
|
||||
|
||||
class BleScanResult {
|
||||
const BleScanResult({
|
||||
required this.deviceId,
|
||||
required this.name,
|
||||
required this.rssi,
|
||||
});
|
||||
|
||||
final String deviceId;
|
||||
final String name;
|
||||
final int rssi;
|
||||
}
|
||||
|
||||
class BleProvisioner {
|
||||
// TODO: implement using flutter_blue_plus.
|
||||
// Filter scan by the ESP-IDF provisioning service UUID advertised by your firmware.
|
||||
|
||||
/// Starts a BLE scan and emits discovered ESP32 provisioning devices.
|
||||
Stream<BleScanResult> scan() => throw UnimplementedError();
|
||||
|
||||
Future<void> stopScan() async => throw UnimplementedError();
|
||||
|
||||
/// Connects to [deviceId] and sends WiFi credentials via the ESP-IDF
|
||||
/// Unified Provisioning GATT profile (protobuf over BLE characteristic).
|
||||
Future<void> provision(
|
||||
String deviceId, {
|
||||
required String ssid,
|
||||
required String wifiPassword,
|
||||
}) async =>
|
||||
throw UnimplementedError();
|
||||
|
||||
void dispose() {}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
typedef Credentials = ({String username, String password});
|
||||
|
||||
class CredentialStore {
|
||||
static const _usernameKey = 'localiserd_username';
|
||||
static const _passwordKey = 'localiserd_password';
|
||||
|
||||
final _storage = const FlutterSecureStorage();
|
||||
|
||||
Future<void> save(Credentials credentials) => Future.wait([
|
||||
_storage.write(key: _usernameKey, value: credentials.username),
|
||||
_storage.write(key: _passwordKey, value: credentials.password),
|
||||
]).then((_) {});
|
||||
|
||||
Future<Credentials?> load() async {
|
||||
final results = await Future.wait([
|
||||
_storage.read(key: _usernameKey),
|
||||
_storage.read(key: _passwordKey),
|
||||
]);
|
||||
final username = results[0];
|
||||
final password = results[1];
|
||||
if (username == null || password == null) return null;
|
||||
return (username: username, password: password);
|
||||
}
|
||||
|
||||
Future<void> clear() => Future.wait([
|
||||
_storage.delete(key: _usernameKey),
|
||||
_storage.delete(key: _passwordKey),
|
||||
]).then((_) {});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import '../../../domain/models/server_config.dart';
|
||||
import 'localiser_client.dart';
|
||||
|
||||
class FloorClient extends LocaliserdClient {
|
||||
FloorClient({required super.config, required String super.token});
|
||||
|
||||
Future<List<dynamic>> getFloors() async =>
|
||||
await get('/api/floors') as List<dynamic>;
|
||||
|
||||
Future<Map<String, dynamic>> getFloor(int id) async =>
|
||||
await get('/api/floors/$id') as Map<String, dynamic>;
|
||||
|
||||
Future<Map<String, dynamic>> createFloor(Map<String, dynamic> params) async =>
|
||||
await post('/api/floors', params) as Map<String, dynamic>;
|
||||
|
||||
Future<Map<String, dynamic>> updateFloor(
|
||||
int id, Map<String, dynamic> params) async =>
|
||||
await patch('/api/floors/$id', params) as Map<String, dynamic>;
|
||||
|
||||
Future<void> deleteFloor(int id) => delete('/api/floors/$id');
|
||||
|
||||
Future<List<dynamic>> getRooms(int floorId) async =>
|
||||
await get('/api/floors/$floorId/rooms') as List<dynamic>;
|
||||
|
||||
Future<Map<String, dynamic>> createRoom(
|
||||
int floorId, Map<String, dynamic> params) async =>
|
||||
await post('/api/floors/$floorId/rooms', params) as Map<String, dynamic>;
|
||||
|
||||
Future<Map<String, dynamic>> updateRoom(
|
||||
int floorId, int id, Map<String, dynamic> params) async =>
|
||||
await patch('/api/floors/$floorId/rooms/$id', params)
|
||||
as Map<String, dynamic>;
|
||||
|
||||
Future<void> deleteRoom(int floorId, int id) =>
|
||||
delete('/api/floors/$floorId/rooms/$id');
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import '../../../domain/models/server_config.dart';
|
||||
|
||||
class ApiException implements Exception {
|
||||
const ApiException(this.statusCode, this.message);
|
||||
final int statusCode;
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => 'ApiException $statusCode: $message';
|
||||
}
|
||||
|
||||
abstract class LocaliserdClient {
|
||||
const LocaliserdClient({required this.config, this.token});
|
||||
|
||||
final ServerConfig config;
|
||||
|
||||
/// Null for unauthenticated clients (session, onboarding setup).
|
||||
final String? token;
|
||||
|
||||
Uri _uri(String path) => Uri(
|
||||
scheme: 'http',
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
path: path,
|
||||
);
|
||||
|
||||
Future<dynamic> get(String path) => _request('GET', path);
|
||||
Future<dynamic> post(String path, [Object? body]) => _request('POST', path, body);
|
||||
Future<dynamic> put(String path, [Object? body]) => _request('PUT', path, body);
|
||||
Future<dynamic> patch(String path, [Object? body]) => _request('PATCH', path, body);
|
||||
Future<void> delete(String path) => _request('DELETE', path).then((_) {});
|
||||
|
||||
/// Like [delete] but returns the response body (for endpoints that do so).
|
||||
Future<dynamic> deleteBody(String path) => _request('DELETE', path);
|
||||
|
||||
Future<dynamic> _request(String method, String path, [Object? body]) async {
|
||||
final http = HttpClient();
|
||||
try {
|
||||
final request = await http.openUrl(method, _uri(path));
|
||||
request.headers.contentType = ContentType.json;
|
||||
if (token != null) {
|
||||
request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $token');
|
||||
}
|
||||
if (body != null) request.write(jsonEncode(body));
|
||||
|
||||
final response = await request.close();
|
||||
if (response.statusCode == 204) return null;
|
||||
|
||||
final responseBody = await response.transform(utf8.decoder).join();
|
||||
if (response.statusCode >= 400) {
|
||||
final parsed = responseBody.isNotEmpty
|
||||
? jsonDecode(responseBody) as Map<String, dynamic>?
|
||||
: null;
|
||||
throw ApiException(
|
||||
response.statusCode,
|
||||
parsed?['error']?.toString() ?? responseBody,
|
||||
);
|
||||
}
|
||||
|
||||
return responseBody.isEmpty ? null : jsonDecode(responseBody);
|
||||
} finally {
|
||||
http.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import '../../../domain/models/auth.dart';
|
||||
import '../../../domain/models/server_config.dart';
|
||||
import 'localiser_client.dart';
|
||||
|
||||
class OnboardingChecklist {
|
||||
const OnboardingChecklist({
|
||||
required this.hasAdmin,
|
||||
required this.hasFloors,
|
||||
required this.hasRooms,
|
||||
required this.hasSensorsPlaced,
|
||||
required this.hasTags,
|
||||
});
|
||||
|
||||
final bool hasAdmin;
|
||||
final bool hasFloors;
|
||||
final bool hasRooms;
|
||||
final bool hasSensorsPlaced;
|
||||
final bool hasTags;
|
||||
|
||||
factory OnboardingChecklist.fromJson(Map<String, dynamic> json) =>
|
||||
OnboardingChecklist(
|
||||
hasAdmin: json['has_admin'] as bool,
|
||||
hasFloors: json['has_floors'] as bool,
|
||||
hasRooms: json['has_rooms'] as bool,
|
||||
hasSensorsPlaced: json['has_sensors_placed'] as bool,
|
||||
hasTags: json['has_tags'] as bool,
|
||||
);
|
||||
}
|
||||
|
||||
class OnboardingClient extends LocaliserdClient {
|
||||
OnboardingClient({required super.config});
|
||||
|
||||
Future<OnboardingChecklist> getChecklist() async {
|
||||
final json = await get('/api/onboarding') as Map<String, dynamic>;
|
||||
return OnboardingChecklist.fromJson(json);
|
||||
}
|
||||
|
||||
/// Creates the first admin user. Only succeeds when no users exist yet.
|
||||
/// Returns the JWT so the caller can immediately authenticate.
|
||||
Future<TokenResponse> setup(String username, String password) async {
|
||||
final json = await post('/api/setup', {
|
||||
'username': username,
|
||||
'password': password,
|
||||
}) as Map<String, dynamic>;
|
||||
return TokenResponse.fromJson(json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:phoenix_socket/phoenix_socket.dart';
|
||||
|
||||
import '../../../domain/models/server_config.dart';
|
||||
|
||||
class RealtimeDataClient {
|
||||
RealtimeDataClient({required this.config, required this.token});
|
||||
|
||||
final ServerConfig config;
|
||||
final String token;
|
||||
|
||||
PhoenixSocket? _socket;
|
||||
final _channels = <String, PhoenixChannel>{};
|
||||
|
||||
Future<void> connect() async {
|
||||
_socket = PhoenixSocket(
|
||||
config.wsUrl,
|
||||
socketOptions: PhoenixSocketOptions(
|
||||
params: {'token': token},
|
||||
),
|
||||
);
|
||||
await _socket!.connect();
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
for (final channel in _channels.values) {
|
||||
channel.leave();
|
||||
}
|
||||
_channels.clear();
|
||||
_socket?.close();
|
||||
_socket = null;
|
||||
}
|
||||
|
||||
bool get isConnected => _socket?.isConnected ?? false;
|
||||
|
||||
/// Joins [topic] (if not already joined) and returns a stream of message
|
||||
/// payloads for that topic. The stream stays open until [disconnect] is
|
||||
/// called or the underlying socket closes.
|
||||
Stream<Map<String, dynamic>> channel(
|
||||
String topic, {
|
||||
Map<String, dynamic> params = const {},
|
||||
}) {
|
||||
final socket = _socket;
|
||||
if (socket == null) throw StateError('RealtimeDataClient not connected');
|
||||
|
||||
final channel = _channels.putIfAbsent(
|
||||
topic,
|
||||
() {
|
||||
final ch = socket.addChannel(topic: topic, parameters: params);
|
||||
ch.join();
|
||||
return ch;
|
||||
},
|
||||
);
|
||||
|
||||
return channel.messages
|
||||
.where((msg) => msg.event.value != 'phx_reply')
|
||||
.map((msg) => msg.payload ?? const {});
|
||||
}
|
||||
|
||||
/// Pushes [event] on [topic] and waits for the server reply.
|
||||
/// The channel must have been joined first via [channel].
|
||||
Future<Map<String, dynamic>> push(
|
||||
String topic,
|
||||
String event,
|
||||
Map<String, dynamic> payload,
|
||||
) async {
|
||||
final ch = _channels[topic];
|
||||
if (ch == null) throw StateError('Channel $topic has not been joined');
|
||||
|
||||
final reply = await ch.push(event, payload).future;
|
||||
return (reply.response as Map<String, dynamic>?) ?? const {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import '../../../domain/models/server_config.dart';
|
||||
import 'localiser_client.dart';
|
||||
|
||||
class SensorClient extends LocaliserdClient {
|
||||
SensorClient({required ServerConfig config, required String token})
|
||||
: super(config: config, token: token);
|
||||
|
||||
Future<List<dynamic>> getSensors() async =>
|
||||
await get('/api/sensors') as List<dynamic>;
|
||||
|
||||
Future<List<dynamic>> getUnplacedSensors() async =>
|
||||
await get('/api/sensors/unplaced') as List<dynamic>;
|
||||
|
||||
Future<Map<String, dynamic>> getSensor(int id) async =>
|
||||
await get('/api/sensors/$id') as Map<String, dynamic>;
|
||||
|
||||
Future<Map<String, dynamic>> updateSensor(
|
||||
int id, Map<String, dynamic> params) async =>
|
||||
await put('/api/sensors/$id', params) as Map<String, dynamic>;
|
||||
|
||||
Future<void> deleteSensor(int id) => delete('/api/sensors/$id');
|
||||
|
||||
Future<Map<String, dynamic>> placeSensor(
|
||||
int id, Map<String, dynamic> params) async =>
|
||||
await put('/api/sensors/$id/place', params) as Map<String, dynamic>;
|
||||
|
||||
Future<Map<String, dynamic>> unplaceSensor(int id) async =>
|
||||
await deleteBody('/api/sensors/$id/place') as Map<String, dynamic>;
|
||||
|
||||
Future<Map<String, dynamic>> startCalibration(
|
||||
int id, double referenceDistance) async =>
|
||||
await post('/api/sensors/$id/calibration/start',
|
||||
{'reference_distance': referenceDistance}) as Map<String, dynamic>;
|
||||
|
||||
Future<Map<String, dynamic>> stopCalibration(int id) async =>
|
||||
await post('/api/sensors/$id/calibration/stop') as Map<String, dynamic>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import '../../../domain/models/auth.dart';
|
||||
import '../../../domain/models/server_config.dart';
|
||||
import 'localiser_client.dart';
|
||||
|
||||
class SessionClient extends LocaliserdClient {
|
||||
SessionClient({required super.config});
|
||||
|
||||
Future<TokenResponse> login(String username, String password) async {
|
||||
final json = await post('/api/session', {
|
||||
'username': username,
|
||||
'password': password,
|
||||
}) as Map<String, dynamic>;
|
||||
return TokenResponse.fromJson(json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import '../../../domain/models/server_config.dart';
|
||||
import 'localiser_client.dart';
|
||||
|
||||
class TagClient extends LocaliserdClient {
|
||||
TagClient({required super.config, required String super.token});
|
||||
|
||||
Future<List<dynamic>> getTags() async =>
|
||||
await get('/api/tags') as List<dynamic>;
|
||||
|
||||
Future<Map<String, dynamic>> getTag(int id) async =>
|
||||
await get('/api/tags/$id') as Map<String, dynamic>;
|
||||
|
||||
Future<Map<String, dynamic>> createTag(Map<String, dynamic> params) async =>
|
||||
await post('/api/tags', params) as Map<String, dynamic>;
|
||||
|
||||
Future<Map<String, dynamic>> updateTag(
|
||||
int id, Map<String, dynamic> params) async =>
|
||||
await patch('/api/tags/$id', params) as Map<String, dynamic>;
|
||||
|
||||
Future<void> deleteTag(int id) => delete('/api/tags/$id');
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:multicast_dns/multicast_dns.dart';
|
||||
|
||||
import '../../../domain/models/server_config.dart';
|
||||
|
||||
class MdnsDiscovery {
|
||||
static const String serviceType = '_localiserd._tcp';
|
||||
|
||||
MDnsClient? _client;
|
||||
|
||||
Stream<ServerConfig> discover() async* {
|
||||
_client?.stop();
|
||||
final client = MDnsClient();
|
||||
_client = client;
|
||||
await client.start();
|
||||
try {
|
||||
await for (final ptr in client.lookup<PtrResourceRecord>(
|
||||
ResourceRecordQuery.serverPointer(serviceType),
|
||||
)) {
|
||||
await for (final srv in client.lookup<SrvResourceRecord>(
|
||||
ResourceRecordQuery.service(ptr.domainName),
|
||||
)) {
|
||||
await for (final ip in client.lookup<IPAddressResourceRecord>(
|
||||
ResourceRecordQuery.addressIPv4(srv.target),
|
||||
)) {
|
||||
yield ServerConfig(host: ip.address.address, port: srv.port);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
client.stop();
|
||||
if (_client == client) _client = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
_client?.stop();
|
||||
_client = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
class User {
|
||||
const User({required this.id, required this.username, required this.isAdmin});
|
||||
|
||||
final int id;
|
||||
final String username;
|
||||
final bool isAdmin;
|
||||
|
||||
factory User.fromJson(Map<String, dynamic> json) => User(
|
||||
id: json['id'] as int,
|
||||
username: json['username'] as String,
|
||||
isAdmin: json['is_admin'] as bool,
|
||||
);
|
||||
}
|
||||
|
||||
class TokenResponse {
|
||||
const TokenResponse({required this.token, required this.user});
|
||||
|
||||
final String token;
|
||||
final User user;
|
||||
|
||||
factory TokenResponse.fromJson(Map<String, dynamic> json) => TokenResponse(
|
||||
token: json['token'] as String,
|
||||
user: User.fromJson(json['user'] as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'position.dart';
|
||||
|
||||
class Room {
|
||||
const Room({required this.id, required this.name, required this.polygon});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
/// Polygon vertices in normalised 0..1 coordinates.
|
||||
final List<Position> polygon;
|
||||
|
||||
Room copyWith({String? name, List<Position>? polygon}) =>
|
||||
Room(id: id, name: name ?? this.name, polygon: polygon ?? this.polygon);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'polygon': polygon.map((p) => p.toJson()).toList(),
|
||||
};
|
||||
|
||||
factory Room.fromJson(Map<String, dynamic> json) => Room(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
polygon: (json['polygon'] as List)
|
||||
.map((p) => Position.fromJson(p as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
class FloorPlan {
|
||||
const FloorPlan({
|
||||
required this.id,
|
||||
required this.floorId,
|
||||
required this.metersPerUnit,
|
||||
required this.rooms,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String floorId;
|
||||
|
||||
/// Scale: how many real-world meters one normalised unit represents.
|
||||
final double metersPerUnit;
|
||||
|
||||
final List<Room> rooms;
|
||||
|
||||
FloorPlan copyWith({double? metersPerUnit, List<Room>? rooms}) => FloorPlan(
|
||||
id: id,
|
||||
floorId: floorId,
|
||||
metersPerUnit: metersPerUnit ?? this.metersPerUnit,
|
||||
rooms: rooms ?? this.rooms,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'floor_id': floorId,
|
||||
'meters_per_unit': metersPerUnit,
|
||||
'rooms': rooms.map((r) => r.toJson()).toList(),
|
||||
};
|
||||
|
||||
factory FloorPlan.fromJson(Map<String, dynamic> json) => FloorPlan(
|
||||
id: json['id'] as String,
|
||||
floorId: json['floor_id'] as String,
|
||||
metersPerUnit: (json['meters_per_unit'] as num).toDouble(),
|
||||
rooms: (json['rooms'] as List)
|
||||
.map((r) => Room.fromJson(r as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
enum FloorPlanMode { view, edit }
|
||||
@@ -0,0 +1,13 @@
|
||||
enum OnboardingStatus {
|
||||
/// No admin user exists yet.
|
||||
notStarted,
|
||||
|
||||
/// Admin user created, floor plan not yet saved.
|
||||
awaitingFloorPlan,
|
||||
|
||||
/// Floor plan saved, no sensors enrolled yet.
|
||||
awaitingFirstSensor,
|
||||
|
||||
/// At least one sensor enrolled; onboarding considered complete.
|
||||
complete,
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/// Single particle in a particle filter cloud snapshot.
|
||||
class Particle {
|
||||
const Particle({required this.x, required this.y, required this.weight});
|
||||
|
||||
/// Normalised 0..1 coordinates (same space as [Position]).
|
||||
final double x;
|
||||
final double y;
|
||||
|
||||
/// Unnormalised likelihood weight.
|
||||
final double weight;
|
||||
|
||||
factory Particle.fromJson(Map<String, dynamic> json) => Particle(
|
||||
x: (json['x'] as num).toDouble(),
|
||||
y: (json['y'] as num).toDouble(),
|
||||
weight: (json['weight'] as num).toDouble(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Normalised coordinates: x and y are in the range 0..1 relative to the floor plan canvas.
|
||||
class Position {
|
||||
const Position({required this.x, required this.y});
|
||||
|
||||
final double x;
|
||||
final double y;
|
||||
|
||||
Position copyWith({double? x, double? y}) =>
|
||||
Position(x: x ?? this.x, y: y ?? this.y);
|
||||
|
||||
// Key names should match localiserd API fields.
|
||||
Map<String, dynamic> toJson() => {'x': x, 'y': y};
|
||||
|
||||
factory Position.fromJson(Map<String, dynamic> json) => Position(
|
||||
x: (json['x'] as num).toDouble(),
|
||||
y: (json['y'] as num).toDouble(),
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is Position && other.x == x && other.y == y;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(x, y);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'position.dart';
|
||||
|
||||
enum SensorStatus { online, offline, provisioning }
|
||||
|
||||
class Sensor {
|
||||
const Sensor({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.floorId,
|
||||
required this.position,
|
||||
required this.status,
|
||||
this.lastSeen,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String floorId;
|
||||
final Position position;
|
||||
final SensorStatus status;
|
||||
final DateTime? lastSeen;
|
||||
|
||||
Sensor copyWith({
|
||||
String? name,
|
||||
Position? position,
|
||||
SensorStatus? status,
|
||||
DateTime? lastSeen,
|
||||
}) =>
|
||||
Sensor(
|
||||
id: id,
|
||||
name: name ?? this.name,
|
||||
floorId: floorId,
|
||||
position: position ?? this.position,
|
||||
status: status ?? this.status,
|
||||
lastSeen: lastSeen ?? this.lastSeen,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'floor_id': floorId,
|
||||
'position': position.toJson(),
|
||||
'status': status.name,
|
||||
'last_seen': lastSeen?.toIso8601String(),
|
||||
};
|
||||
|
||||
factory Sensor.fromJson(Map<String, dynamic> json) => Sensor(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
floorId: json['floor_id'] as String,
|
||||
position: Position.fromJson(json['position'] as Map<String, dynamic>),
|
||||
status: SensorStatus.values.byName(json['status'] as String),
|
||||
lastSeen: json['last_seen'] == null
|
||||
? null
|
||||
: DateTime.parse(json['last_seen'] as String),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
class ServerConfig {
|
||||
const ServerConfig({required this.host, required this.port});
|
||||
|
||||
final String host;
|
||||
final int port;
|
||||
|
||||
String get wsUrl => 'ws://$host:$port/socket/websocket';
|
||||
|
||||
ServerConfig copyWith({String? host, int? port}) =>
|
||||
ServerConfig(host: host ?? this.host, port: port ?? this.port);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is ServerConfig && other.host == host && other.port == port;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(host, port);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'position.dart';
|
||||
|
||||
class Tag {
|
||||
const Tag({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.currentRoomId,
|
||||
this.lastPosition,
|
||||
this.lastSeen,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String? currentRoomId;
|
||||
final Position? lastPosition;
|
||||
final DateTime? lastSeen;
|
||||
|
||||
Tag copyWith({String? name, String? currentRoomId, Position? lastPosition, DateTime? lastSeen}) =>
|
||||
Tag(
|
||||
id: id,
|
||||
name: name ?? this.name,
|
||||
currentRoomId: currentRoomId ?? this.currentRoomId,
|
||||
lastPosition: lastPosition ?? this.lastPosition,
|
||||
lastSeen: lastSeen ?? this.lastSeen,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'current_room_id': currentRoomId,
|
||||
'last_position': lastPosition?.toJson(),
|
||||
'last_seen': lastSeen?.toIso8601String(),
|
||||
};
|
||||
|
||||
factory Tag.fromJson(Map<String, dynamic> json) => Tag(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
currentRoomId: json['current_room_id'] as String?,
|
||||
lastPosition: json['last_position'] == null
|
||||
? null
|
||||
: Position.fromJson(json['last_position'] as Map<String, dynamic>),
|
||||
lastSeen: json['last_seen'] == null
|
||||
? null
|
||||
: DateTime.parse(json['last_seen'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
// Live position snapshot pushed from localiserd over Phoenix channel.
|
||||
class TagPosition {
|
||||
const TagPosition({required this.tagId, required this.position});
|
||||
|
||||
final String tagId;
|
||||
final Position position;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../data/sources/ble/ble_provisioner.dart';
|
||||
|
||||
// Shared bottom sheet used by onboarding and the main sensor screens.
|
||||
// Flow: scan → select device → enter WiFi credentials → provision → place on map.
|
||||
class BleProvisionSheet extends ConsumerStatefulWidget {
|
||||
const BleProvisionSheet({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<BleProvisionSheet> createState() => _BleProvisionSheetState();
|
||||
}
|
||||
|
||||
class _BleProvisionSheetState extends ConsumerState<BleProvisionSheet> {
|
||||
final _provisioner = BleProvisioner();
|
||||
final _ssidController = TextEditingController();
|
||||
final _wifiPasswordController = TextEditingController();
|
||||
|
||||
BleScanResult? _selected;
|
||||
bool _provisioning = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_provisioner.dispose();
|
||||
_ssidController.dispose();
|
||||
_wifiPasswordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _provision() async {
|
||||
if (_selected == null) return;
|
||||
setState(() {
|
||||
_provisioning = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await _provisioner.provision(
|
||||
_selected!.deviceId,
|
||||
ssid: _ssidController.text.trim(),
|
||||
wifiPassword: _wifiPasswordController.text,
|
||||
);
|
||||
// TODO: poll localiserd until sensor appears, then prompt placement on map.
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
} catch (e) {
|
||||
setState(() => _error = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _provisioning = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DraggableScrollableSheet(
|
||||
expand: false,
|
||||
initialChildSize: 0.6,
|
||||
maxChildSize: 0.9,
|
||||
builder: (context, scrollController) => Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
24,
|
||||
16,
|
||||
24,
|
||||
MediaQuery.of(context).viewInsets.bottom + 24,
|
||||
),
|
||||
child: ListView(
|
||||
controller: scrollController,
|
||||
children: [
|
||||
Text('Add sensor',
|
||||
style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Scan results
|
||||
// TODO: StreamBuilder on _provisioner.scan() — show a list of
|
||||
// BleScanResult tiles; tapping one sets _selected.
|
||||
const Text('Nearby ESP32 devices'),
|
||||
const SizedBox(height: 8),
|
||||
const Placeholder(fallbackHeight: 120),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
if (_selected != null) ...[
|
||||
Text('Selected: ${_selected!.name}'),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _ssidController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'WiFi SSID',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _wifiPasswordController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'WiFi password',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
obscureText: true,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(_error!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error)),
|
||||
),
|
||||
|
||||
FilledButton(
|
||||
onPressed: (_selected == null || _provisioning) ? null : _provision,
|
||||
child: _provisioning
|
||||
? const SizedBox.square(
|
||||
dimension: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('Provision & add'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../data/sources/localiser/realtime_data_client.dart';
|
||||
import '../../providers.dart';
|
||||
|
||||
class LoginScreen extends ConsumerStatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tryAutoLogin();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _tryAutoLogin() async {
|
||||
final store = ref.read(credentialStoreProvider);
|
||||
final saved = await store.load();
|
||||
if (saved == null || !mounted) return;
|
||||
|
||||
_usernameController.text = saved.username;
|
||||
_passwordController.text = saved.password;
|
||||
await _login(saved.username, saved.password, saveCredentials: false);
|
||||
}
|
||||
|
||||
Future<void> _login(String username, String password,
|
||||
{bool saveCredentials = true}) async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final client = ref.read(sessionClientProvider);
|
||||
final tokenResponse = await client.login(username, password);
|
||||
final token = tokenResponse.token;
|
||||
|
||||
ref.read(authTokenProvider.notifier).state = token;
|
||||
|
||||
final config = ref.read(serverConfigProvider)!;
|
||||
final realtime = RealtimeDataClient(config: config, token: token);
|
||||
await realtime.connect();
|
||||
ref.read(realtimeDataClientProvider.notifier).state = realtime;
|
||||
|
||||
if (saveCredentials) {
|
||||
await ref
|
||||
.read(credentialStoreProvider)
|
||||
.save((username: username, password: password));
|
||||
}
|
||||
|
||||
if (mounted) context.go('/floorplan');
|
||||
} on Exception catch (e) {
|
||||
setState(() => _error = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Sign In')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _usernameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Username',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
autofillHints: const [AutofillHints.username],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Password',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
obscureText: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
autofillHints: const [AutofillHints.password],
|
||||
onSubmitted: _loading
|
||||
? null
|
||||
: (_) => _login(
|
||||
_usernameController.text.trim(),
|
||||
_passwordController.text,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
_error!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _loading
|
||||
? null
|
||||
: () => _login(
|
||||
_usernameController.text.trim(),
|
||||
_passwordController.text,
|
||||
),
|
||||
child: _loading
|
||||
? const SizedBox.square(
|
||||
dimension: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Sign in'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../data/sources/localiser/onboarding_client.dart';
|
||||
import '../../data/sources/mdns/mdns_discovery.dart';
|
||||
import '../../domain/models/server_config.dart';
|
||||
import '../../providers.dart';
|
||||
|
||||
class ServerDiscoveryScreen extends ConsumerStatefulWidget {
|
||||
const ServerDiscoveryScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ServerDiscoveryScreen> createState() =>
|
||||
_ServerDiscoveryScreenState();
|
||||
}
|
||||
|
||||
class _ServerDiscoveryScreenState extends ConsumerState<ServerDiscoveryScreen> {
|
||||
final _hostController = TextEditingController();
|
||||
final _portController = TextEditingController(text: '4000');
|
||||
bool _connecting = false;
|
||||
String? _error;
|
||||
|
||||
final _discovery = MdnsDiscovery();
|
||||
final _discoveredServers = <ServerConfig>[];
|
||||
StreamSubscription<ServerConfig>? _discoverySub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_discoverySub = _discovery.discover().listen(
|
||||
(server) {
|
||||
if (!_discoveredServers.contains(server)) {
|
||||
setState(() => _discoveredServers.add(server));
|
||||
}
|
||||
},
|
||||
onError: (_) {},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_discoverySub?.cancel();
|
||||
_hostController.dispose();
|
||||
_portController.dispose();
|
||||
_discovery.stop();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _connect(ServerConfig config) async {
|
||||
setState(() {
|
||||
_connecting = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final checklist =
|
||||
await OnboardingClient(config: config).getChecklist();
|
||||
|
||||
ref.read(serverConfigProvider.notifier).state = config;
|
||||
|
||||
if (!mounted) return;
|
||||
if (!checklist.hasAdmin) {
|
||||
context.go('/onboarding');
|
||||
} else {
|
||||
context.go('/login');
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _error = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _connecting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Connect to Server')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text('Discovered servers'),
|
||||
const SizedBox(height: 8),
|
||||
if (_discoveredServers.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 32),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Scanning…',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: _discoveredServers.length,
|
||||
itemBuilder: (context, i) {
|
||||
final server = _discoveredServers[i];
|
||||
return ListTile(
|
||||
title: Text(server.host),
|
||||
subtitle: Text('Port ${server.port}'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: _connecting ? null : () => _connect(server),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Manual entry'),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _hostController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Host / IP',
|
||||
hintText: '192.168.1.100',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _portController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Port',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
_error!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _connecting
|
||||
? null
|
||||
: () => _connect(ServerConfig(
|
||||
host: _hostController.text.trim(),
|
||||
port: int.tryParse(_portController.text) ?? 4000,
|
||||
)),
|
||||
child: _connecting
|
||||
? const SizedBox.square(
|
||||
dimension: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Connect'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../domain/models/floor_plan_mode.dart';
|
||||
import '../../providers.dart';
|
||||
import '../ble_provision/ble_provision_sheet.dart';
|
||||
import 'widgets/konva_web_view.dart';
|
||||
|
||||
class FloorPlanScreen extends ConsumerStatefulWidget {
|
||||
const FloorPlanScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<FloorPlanScreen> createState() => _FloorPlanScreenState();
|
||||
}
|
||||
|
||||
class _FloorPlanScreenState extends ConsumerState<FloorPlanScreen> {
|
||||
final _konvaKey = GlobalKey<KonvaWebViewState>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mode = ref.watch(floorPlanModeProvider);
|
||||
|
||||
// TODO: forward live tag positions into the WebView.
|
||||
// ref.listen(tagPositionsProvider, (_, next) {
|
||||
// next.whenData((positions) => _konvaKey.currentState?.updateTags(positions));
|
||||
// });
|
||||
|
||||
// TODO: forward particle cloud updates into the WebView.
|
||||
// ref.listen(particleCloudProvider, (_, next) {
|
||||
// next.whenData((particles) => _konvaKey.currentState?.updateParticleCloud(particles));
|
||||
// });
|
||||
|
||||
// TODO: react to selectedSensorIdProvider and highlight sensor in WebView.
|
||||
// ref.listen(selectedSensorIdProvider, (_, id) {
|
||||
// _konvaKey.currentState?.highlightSensor(id);
|
||||
// });
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Floor Plan'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: mode == FloorPlanMode.edit ? 'View mode' : 'Edit mode',
|
||||
icon: Icon(
|
||||
mode == FloorPlanMode.edit ? Icons.visibility : Icons.edit,
|
||||
),
|
||||
onPressed: () {
|
||||
final next = mode == FloorPlanMode.edit
|
||||
? FloorPlanMode.view
|
||||
: FloorPlanMode.edit;
|
||||
ref.read(floorPlanModeProvider.notifier).state = next;
|
||||
_konvaKey.currentState?.setMode(next);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: KonvaWebView(
|
||||
key: _konvaKey,
|
||||
mode: mode,
|
||||
onSensorTapped: (id) {
|
||||
ref.read(selectedSensorIdProvider.notifier).state = id;
|
||||
// TODO: optionally navigate to sensor detail or show tooltip.
|
||||
},
|
||||
onSensorMoved: (id, position) {
|
||||
// TODO: persist new position via sensorRepositoryProvider.
|
||||
},
|
||||
),
|
||||
floatingActionButton: mode == FloorPlanMode.edit
|
||||
? FloatingActionButton.extended(
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add sensor'),
|
||||
onPressed: () => showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => const BleProvisionSheet(),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
import '../../../domain/models/floor_plan_mode.dart';
|
||||
import '../../../domain/models/tag.dart';
|
||||
import '../../../domain/models/particle.dart';
|
||||
import '../../../domain/models/position.dart';
|
||||
|
||||
class KonvaWebView extends StatefulWidget {
|
||||
const KonvaWebView({
|
||||
super.key,
|
||||
required this.mode,
|
||||
required this.onSensorTapped,
|
||||
required this.onSensorMoved,
|
||||
});
|
||||
|
||||
final FloorPlanMode mode;
|
||||
final void Function(String sensorId) onSensorTapped;
|
||||
final void Function(String sensorId, Position newPosition) onSensorMoved;
|
||||
|
||||
@override
|
||||
State<KonvaWebView> createState() => KonvaWebViewState();
|
||||
}
|
||||
|
||||
class KonvaWebViewState extends State<KonvaWebView> {
|
||||
late final WebViewController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..addJavaScriptChannel('FlutterBridge', onMessageReceived: _onMessage)
|
||||
..loadFlutterAsset('assets/konva/index.html');
|
||||
}
|
||||
|
||||
void _onMessage(JavaScriptMessage message) {
|
||||
final data = jsonDecode(message.message) as Map<String, dynamic>;
|
||||
switch (data['type'] as String?) {
|
||||
case 'sensorTapped':
|
||||
widget.onSensorTapped(data['id'] as String);
|
||||
case 'sensorMoved':
|
||||
widget.onSensorMoved(
|
||||
data['id'] as String,
|
||||
Position(
|
||||
x: (data['x'] as num).toDouble(),
|
||||
y: (data['y'] as num).toDouble(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateTags(List<TagPosition> positions) async {
|
||||
final payload = jsonEncode(positions
|
||||
.map((p) => {'tagId': p.tagId, 'x': p.position.x, 'y': p.position.y})
|
||||
.toList());
|
||||
await _controller.runJavaScript('window.companion.updateTags($payload)');
|
||||
}
|
||||
|
||||
Future<void> updateParticleCloud(List<Particle> particles) async {
|
||||
final payload = jsonEncode(particles
|
||||
.map((p) => {'x': p.x, 'y': p.y, 'weight': p.weight})
|
||||
.toList());
|
||||
await _controller.runJavaScript('window.companion.updateCloud($payload)');
|
||||
}
|
||||
|
||||
Future<void> highlightSensor(String? sensorId) async {
|
||||
final id = sensorId == null ? 'null' : '"$sensorId"';
|
||||
await _controller.runJavaScript('window.companion.highlightSensor($id)');
|
||||
}
|
||||
|
||||
Future<void> setMode(FloorPlanMode mode) async {
|
||||
await _controller.runJavaScript(
|
||||
'window.companion.setMode("${mode.name}")',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => WebViewWidget(controller: _controller);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'steps/step_admin_user.dart';
|
||||
import 'steps/step_floor_plan.dart';
|
||||
import 'steps/step_sensors.dart';
|
||||
import 'steps/step_tags.dart';
|
||||
import 'steps/step_done.dart';
|
||||
|
||||
class OnboardingScreen extends ConsumerStatefulWidget {
|
||||
const OnboardingScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<OnboardingScreen> createState() => _OnboardingScreenState();
|
||||
}
|
||||
|
||||
class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
||||
int _step = 0;
|
||||
|
||||
void _advance() => setState(() => _step++);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final steps = <Widget>[
|
||||
StepAdminUser(onComplete: _advance),
|
||||
StepFloorPlan(onComplete: _advance),
|
||||
StepSensors(onComplete: _advance),
|
||||
StepTags(onComplete: _advance),
|
||||
StepDone(onComplete: () => context.go('/floorplan')),
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Setup'),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: (_step + 1) / steps.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: steps[_step],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../data/sources/localiser/realtime_data_client.dart';
|
||||
import '../../../providers.dart';
|
||||
|
||||
class StepAdminUser extends ConsumerStatefulWidget {
|
||||
const StepAdminUser({super.key, required this.onComplete});
|
||||
|
||||
final VoidCallback onComplete;
|
||||
|
||||
@override
|
||||
ConsumerState<StepAdminUser> createState() => _StepAdminUserState();
|
||||
}
|
||||
|
||||
class _StepAdminUserState extends ConsumerState<StepAdminUser> {
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final username = _usernameController.text.trim();
|
||||
final password = _passwordController.text;
|
||||
|
||||
final token = await ref.read(onboardingRepositoryProvider).createAdminUser(
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
|
||||
ref.read(authTokenProvider.notifier).state = token;
|
||||
|
||||
final config = ref.read(serverConfigProvider)!;
|
||||
final realtime = RealtimeDataClient(config: config, token: token);
|
||||
await realtime.connect();
|
||||
ref.read(realtimeDataClientProvider.notifier).state = realtime;
|
||||
|
||||
await ref
|
||||
.read(credentialStoreProvider)
|
||||
.save((username: username, password: password));
|
||||
|
||||
widget.onComplete();
|
||||
} catch (e) {
|
||||
setState(() => _error = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Create admin account',
|
||||
style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 24),
|
||||
TextField(
|
||||
controller: _usernameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Username',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Password',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
obscureText: true,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(_error!,
|
||||
style:
|
||||
TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _loading ? null : _submit,
|
||||
child: _loading
|
||||
? const SizedBox.square(
|
||||
dimension: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('Create account'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class StepDone extends StatelessWidget {
|
||||
const StepDone({super.key, required this.onComplete});
|
||||
|
||||
final VoidCallback onComplete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Icon(Icons.check_circle_outline, size: 72),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Setup complete',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Your floor plan and sensors are configured. You can add more sensors and tags at any time from the main screen.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
FilledButton(
|
||||
onPressed: onComplete,
|
||||
child: const Text('Go to floor plan'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class StepFloorPlan extends StatelessWidget {
|
||||
const StepFloorPlan({super.key, required this.onComplete});
|
||||
|
||||
final VoidCallback onComplete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Draw floor plan',
|
||||
style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 16),
|
||||
// TODO: embed KonvaWebView in editor mode (no live overlays).
|
||||
// User draws rooms, sets scale, then taps Continue.
|
||||
const Expanded(child: Placeholder()),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: onComplete,
|
||||
child: const Text('Continue'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../ble_provision/ble_provision_sheet.dart';
|
||||
|
||||
class StepSensors extends StatelessWidget {
|
||||
const StepSensors({super.key, required this.onComplete});
|
||||
|
||||
final VoidCallback onComplete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Enroll sensors', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Add at least one sensor to continue.'),
|
||||
const SizedBox(height: 16),
|
||||
// TODO: list of already-enrolled sensors with placement status.
|
||||
const Expanded(child: Placeholder()),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.bluetooth),
|
||||
label: const Text('Add sensor'),
|
||||
onPressed: () => showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => const BleProvisionSheet(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: onComplete,
|
||||
child: const Text('Continue'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class StepTags extends StatelessWidget {
|
||||
const StepTags({super.key, required this.onComplete});
|
||||
|
||||
final VoidCallback onComplete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Enroll tags', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Tags are optional here — you can enroll them later.'),
|
||||
const SizedBox(height: 16),
|
||||
// TODO: BLE scan list + enrolled tag list, similar to StepSensors.
|
||||
const Expanded(child: Placeholder()),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: onComplete,
|
||||
child: const Text('Continue'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../providers.dart';
|
||||
|
||||
class SensorDetailScreen extends ConsumerWidget {
|
||||
const SensorDetailScreen({super.key, required this.sensorId});
|
||||
|
||||
final String sensorId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// TODO: fetch sensor via sensorRepositoryProvider.getSensor(sensorId).
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('Sensor $sensorId')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// TODO: display sensor fields (name, status, position, last seen).
|
||||
const Placeholder(fallbackHeight: 200),
|
||||
const SizedBox(height: 24),
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.map_outlined),
|
||||
label: const Text('Locate on floor plan'),
|
||||
onPressed: () {
|
||||
ref.read(selectedSensorIdProvider.notifier).state = sensorId;
|
||||
context.go('/floorplan');
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// TODO: re-provision button → show BleProvisionSheet pre-filled.
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.bluetooth),
|
||||
label: const Text('Re-provision WiFi'),
|
||||
onPressed: () {}, // TODO
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
label: const Text('Rename'),
|
||||
onPressed: () {}, // TODO
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
onPressed: () {}, // TODO: confirm dialog then delete
|
||||
child: const Text('Delete sensor'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../providers.dart';
|
||||
import '../ble_provision/ble_provision_sheet.dart';
|
||||
|
||||
class SensorListScreen extends ConsumerWidget {
|
||||
const SensorListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// TODO: replace Placeholder with AsyncValue-driven list.
|
||||
// final sensors = ref.watch(sensorsProvider); // define a FutureProvider
|
||||
|
||||
final selectedId = ref.watch(selectedSensorIdProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Sensors')),
|
||||
body: Column(
|
||||
children: [
|
||||
if (selectedId != null)
|
||||
MaterialBanner(
|
||||
content: Text('Sensor $selectedId selected on floor plan'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
ref.read(selectedSensorIdProvider.notifier).state = null,
|
||||
child: const Text('Dismiss'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.push('/sensors/$selectedId'),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
],
|
||||
),
|
||||
// TODO: ListView.builder with sensor tiles.
|
||||
// Highlight tile whose id == selectedId.
|
||||
const Expanded(child: Placeholder()),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => const BleProvisionSheet(),
|
||||
),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../providers.dart';
|
||||
|
||||
class SettingsScreen extends ConsumerWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final config = ref.watch(serverConfigProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Settings')),
|
||||
body: ListView(
|
||||
children: [
|
||||
ListTile(
|
||||
title: const Text('Server'),
|
||||
subtitle: config == null
|
||||
? const Text('Not connected')
|
||||
: Text('${config.host}:${config.port}'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {}, // TODO: show server config sheet
|
||||
),
|
||||
const Divider(),
|
||||
// TODO: admin account section (change password).
|
||||
const AboutListTile(applicationName: 'Companion'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class MainShell extends StatelessWidget {
|
||||
const MainShell({super.key, required this.shell});
|
||||
|
||||
final StatefulNavigationShell shell;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: shell,
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: shell.currentIndex,
|
||||
onDestinationSelected: (index) => shell.goBranch(
|
||||
index,
|
||||
initialLocation: index == shell.currentIndex,
|
||||
),
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.map_outlined),
|
||||
selectedIcon: Icon(Icons.map),
|
||||
label: 'Floor Plan',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.sensors_outlined),
|
||||
selectedIcon: Icon(Icons.sensors),
|
||||
label: 'Sensors',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.label_outline),
|
||||
selectedIcon: Icon(Icons.label),
|
||||
label: 'Tags',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: 'Settings',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class TagDetailScreen extends ConsumerWidget {
|
||||
const TagDetailScreen({super.key, required this.tagId});
|
||||
|
||||
final String tagId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// TODO: fetch tag via tagRepositoryProvider.getTag(tagId).
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('Tag $tagId')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// TODO: display tag fields (name, current room, last seen, position).
|
||||
const Placeholder(fallbackHeight: 200),
|
||||
const Spacer(),
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
label: const Text('Rename'),
|
||||
onPressed: () {}, // TODO
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
onPressed: () {}, // TODO: confirm dialog then delete
|
||||
child: const Text('Remove tag'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class TagListScreen extends ConsumerWidget {
|
||||
const TagListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// TODO: replace Placeholder with AsyncValue-driven list.
|
||||
// final tags = ref.watch(tagsProvider); // define a FutureProvider
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Tags')),
|
||||
body: const Placeholder(),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () {
|
||||
// TODO: show tag enrollment sheet (BLE scan + manual ID fallback).
|
||||
},
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'router.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const ProviderScope(child: CompanionApp()));
|
||||
}
|
||||
|
||||
class CompanionApp extends ConsumerWidget {
|
||||
const CompanionApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return MaterialApp.router(
|
||||
title: 'localiserd Companion',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||
useMaterial3: true,
|
||||
),
|
||||
routerConfig: ref.watch(routerProvider),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'domain/models/server_config.dart';
|
||||
import 'domain/models/tag.dart';
|
||||
import 'domain/models/particle.dart';
|
||||
import 'domain/models/floor_plan_mode.dart';
|
||||
import 'data/sources/local/credential_store.dart';
|
||||
import 'data/sources/localiser/onboarding_client.dart';
|
||||
import 'data/sources/localiser/session_client.dart';
|
||||
import 'data/sources/localiser/floor_client.dart';
|
||||
import 'data/sources/localiser/sensor_client.dart';
|
||||
import 'data/sources/localiser/tag_client.dart';
|
||||
import 'data/sources/localiser/realtime_data_client.dart';
|
||||
import 'data/repositories/onboarding_repository.dart';
|
||||
import 'data/repositories/sensor_repository.dart';
|
||||
import 'data/repositories/tag_repository.dart';
|
||||
import 'data/repositories/floor_plan_repository.dart';
|
||||
import 'data/repositories/phoenix_onboarding_repository.dart';
|
||||
import 'data/repositories/phoenix_sensor_repository.dart';
|
||||
import 'data/repositories/phoenix_tag_repository.dart';
|
||||
import 'data/repositories/phoenix_floor_plan_repository.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection / auth state — set imperatively after login
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The server the user has selected. Null until a server is chosen.
|
||||
final serverConfigProvider = StateProvider<ServerConfig?>((ref) => null);
|
||||
|
||||
/// JWT returned by /api/session or /api/setup. Null until authenticated.
|
||||
final authTokenProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
/// Live WebSocket connection. Null until [RealtimeDataClient.connect] succeeds.
|
||||
final realtimeDataClientProvider =
|
||||
StateProvider<RealtimeDataClient?>((ref) => null);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convenience
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
final credentialStoreProvider = Provider<CredentialStore>((ref) {
|
||||
return CredentialStore();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Feature clients — throw if required state is missing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ServerConfig _requireConfig(Ref ref) {
|
||||
final config = ref.watch(serverConfigProvider);
|
||||
if (config == null) throw StateError('no server selected');
|
||||
return config;
|
||||
}
|
||||
|
||||
String _requireToken(Ref ref) {
|
||||
final token = ref.watch(authTokenProvider);
|
||||
if (token == null) throw StateError('not authenticated');
|
||||
return token;
|
||||
}
|
||||
|
||||
RealtimeDataClient _requireRealtime(Ref ref) {
|
||||
final rt = ref.watch(realtimeDataClientProvider);
|
||||
if (rt == null) throw StateError('realtime not connected');
|
||||
return rt;
|
||||
}
|
||||
|
||||
final onboardingClientProvider = Provider<OnboardingClient>((ref) {
|
||||
return OnboardingClient(config: _requireConfig(ref));
|
||||
});
|
||||
|
||||
final sessionClientProvider = Provider<SessionClient>((ref) {
|
||||
return SessionClient(config: _requireConfig(ref));
|
||||
});
|
||||
|
||||
final floorClientProvider = Provider<FloorClient>((ref) {
|
||||
return FloorClient(config: _requireConfig(ref), token: _requireToken(ref));
|
||||
});
|
||||
|
||||
final sensorClientProvider = Provider<SensorClient>((ref) {
|
||||
return SensorClient(config: _requireConfig(ref), token: _requireToken(ref));
|
||||
});
|
||||
|
||||
final tagClientProvider = Provider<TagClient>((ref) {
|
||||
return TagClient(config: _requireConfig(ref), token: _requireToken(ref));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Repositories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
final onboardingRepositoryProvider = Provider<OnboardingRepository>((ref) {
|
||||
return PhoenixOnboardingRepository(
|
||||
client: ref.watch(onboardingClientProvider));
|
||||
});
|
||||
|
||||
final sensorRepositoryProvider = Provider<SensorRepository>((ref) {
|
||||
return PhoenixSensorRepository(client: ref.watch(sensorClientProvider));
|
||||
});
|
||||
|
||||
final tagRepositoryProvider = Provider<TagRepository>((ref) {
|
||||
return PhoenixTagRepository(
|
||||
tagClient: ref.watch(tagClientProvider),
|
||||
realtime: _requireRealtime(ref),
|
||||
);
|
||||
});
|
||||
|
||||
final floorPlanRepositoryProvider = Provider<FloorPlanRepository>((ref) {
|
||||
return PhoenixFloorPlanRepository(client: ref.watch(floorClientProvider));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-tab UI state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
final selectedSensorIdProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
final floorPlanModeProvider =
|
||||
StateProvider<FloorPlanMode>((ref) => FloorPlanMode.view);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live data streams
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
final tagPositionsProvider = StreamProvider<List<TagPosition>>((ref) {
|
||||
final repo = ref.watch(tagRepositoryProvider);
|
||||
return repo.watchPositions();
|
||||
});
|
||||
|
||||
final particleCloudProvider = StreamProvider<List<Particle>>((ref) {
|
||||
final repo = ref.watch(tagRepositoryProvider);
|
||||
return repo.watchParticleCloud();
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'features/connection/server_discovery_screen.dart';
|
||||
import 'features/connection/login_screen.dart';
|
||||
import 'features/onboarding/onboarding_screen.dart';
|
||||
import 'features/shell/main_shell.dart';
|
||||
import 'features/floorplan/floor_plan_screen.dart';
|
||||
import 'features/sensors/sensor_list_screen.dart';
|
||||
import 'features/sensors/sensor_detail_screen.dart';
|
||||
import 'features/tags/tag_list_screen.dart';
|
||||
import 'features/tags/tag_detail_screen.dart';
|
||||
import 'features/settings/settings_screen.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
const _unauthenticated = {'/connect', '/login', '/onboarding'};
|
||||
|
||||
final routerProvider = Provider<GoRouter>((ref) {
|
||||
return GoRouter(
|
||||
initialLocation: '/connect',
|
||||
redirect: (context, state) {
|
||||
final hasConfig = ref.read(serverConfigProvider) != null;
|
||||
final hasToken = ref.read(authTokenProvider) != null;
|
||||
final loc = state.matchedLocation;
|
||||
|
||||
if (!hasConfig && loc != '/connect') return '/connect';
|
||||
if (hasConfig && !hasToken && !_unauthenticated.contains(loc)) {
|
||||
return '/login';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/connect',
|
||||
builder: (context, state) => const ServerDiscoveryScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/login',
|
||||
builder: (context, state) => const LoginScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/onboarding',
|
||||
builder: (context, state) => const OnboardingScreen(),
|
||||
),
|
||||
StatefulShellRoute.indexedStack(
|
||||
builder: (context, state, shell) => MainShell(shell: shell),
|
||||
branches: [
|
||||
StatefulShellBranch(routes: [
|
||||
GoRoute(
|
||||
path: '/floorplan',
|
||||
builder: (context, state) => const FloorPlanScreen(),
|
||||
),
|
||||
]),
|
||||
StatefulShellBranch(routes: [
|
||||
GoRoute(
|
||||
path: '/sensors',
|
||||
builder: (context, state) => const SensorListScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: ':id',
|
||||
builder: (context, state) => SensorDetailScreen(
|
||||
sensorId: state.pathParameters['id']!,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]),
|
||||
StatefulShellBranch(routes: [
|
||||
GoRoute(
|
||||
path: '/tags',
|
||||
builder: (context, state) => const TagListScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: ':id',
|
||||
builder: (context, state) => TagDetailScreen(
|
||||
tagId: state.pathParameters['id']!,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]),
|
||||
StatefulShellBranch(routes: [
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
builder: (context, state) => const SettingsScreen(),
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
+690
@@ -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"
|
||||
@@ -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/
|
||||
Reference in New Issue
Block a user