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