2025-07-10 18:52:04 -05:00
|
|
|
import 'dart:convert';
|
|
|
|
|
|
2026-01-02 19:33:24 -06:00
|
|
|
import 'package:blind_master/BlindMasterResources/blind_control_widget.dart';
|
2025-07-10 18:52:04 -05:00
|
|
|
import 'package:blind_master/BlindMasterResources/blindmaster_progress_indicator.dart';
|
|
|
|
|
import 'package:blind_master/BlindMasterResources/error_snackbar.dart';
|
|
|
|
|
import 'package:blind_master/BlindMasterResources/secure_transmissions.dart';
|
|
|
|
|
import 'package:blind_master/BlindMasterResources/text_inputs.dart';
|
2026-03-21 20:30:37 -05:00
|
|
|
import 'package:blind_master/BlindMasterResources/timezone_picker.dart';
|
2025-07-10 18:52:04 -05:00
|
|
|
import 'package:blind_master/BlindMasterScreens/schedules_screen.dart';
|
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
import 'package:google_fonts/google_fonts.dart';
|
|
|
|
|
import 'package:socket_io_client/socket_io_client.dart' as IO;
|
|
|
|
|
|
|
|
|
|
class PeripheralScreen extends StatefulWidget {
|
2026-03-21 00:47:21 -05:00
|
|
|
const PeripheralScreen({super.key, required this.peripheralId, required this.deviceId, required this.peripheralNum, required this.deviceName, this.isSinglePort = false});
|
2025-07-10 18:52:04 -05:00
|
|
|
final int peripheralId;
|
|
|
|
|
final int peripheralNum;
|
|
|
|
|
final int deviceId;
|
2025-12-22 20:26:33 -06:00
|
|
|
final String deviceName;
|
2026-03-21 00:47:21 -05:00
|
|
|
final bool isSinglePort;
|
2025-07-10 18:52:04 -05:00
|
|
|
@override
|
|
|
|
|
State<PeripheralScreen> createState() => _PeripheralScreenState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _PeripheralScreenState extends State<PeripheralScreen> {
|
|
|
|
|
IO.Socket? socket;
|
|
|
|
|
String imagePath = "";
|
2026-03-21 00:47:21 -05:00
|
|
|
late String _deviceName;
|
2025-07-10 18:52:04 -05:00
|
|
|
String peripheralName = "...";
|
|
|
|
|
bool loaded = false;
|
|
|
|
|
bool calibrated = false;
|
|
|
|
|
bool calibrating = false;
|
2025-12-28 14:07:04 -06:00
|
|
|
bool deviceConnected = true; // Track device connection status
|
|
|
|
|
int calibrationStage = 0; // 0=not started, 1=tilt up, 2=tilt down
|
2025-07-10 18:52:04 -05:00
|
|
|
double _blindPosition = 5.0;
|
|
|
|
|
DateTime? lastSet;
|
|
|
|
|
String lastSetMessage = "";
|
2026-03-21 00:00:35 -05:00
|
|
|
int? batterySoc;
|
|
|
|
|
bool _movementPending = false;
|
2026-03-21 00:12:47 -05:00
|
|
|
bool _awaitingDeviceWake = false;
|
2025-07-10 18:52:04 -05:00
|
|
|
|
2026-03-21 20:30:37 -05:00
|
|
|
String? _deviceTimezone;
|
|
|
|
|
|
2025-07-10 18:52:04 -05:00
|
|
|
final _peripheralRenameController = TextEditingController();
|
|
|
|
|
|
|
|
|
|
void getImage() {
|
|
|
|
|
final hour = DateTime.now().hour;
|
|
|
|
|
|
|
|
|
|
if (hour >= 5 && hour < 10) {
|
|
|
|
|
imagePath = 'assets/images/MorningSill.png';
|
|
|
|
|
} else if (hour >= 10 && hour < 18) {
|
|
|
|
|
imagePath = 'assets/images/NoonSill.png';
|
|
|
|
|
} else if (hour >= 18 && hour < 22) {
|
|
|
|
|
imagePath = 'assets/images/EveningSill.png';
|
|
|
|
|
} else {
|
|
|
|
|
imagePath = 'assets/images/NightSill.png';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
2026-03-21 00:47:21 -05:00
|
|
|
_deviceName = widget.deviceName;
|
2025-07-10 18:52:04 -05:00
|
|
|
initAll();
|
|
|
|
|
initSocket();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
|
|
|
|
socket?.disconnect();
|
|
|
|
|
socket?.dispose();
|
|
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> initSocket() async {
|
|
|
|
|
try {
|
|
|
|
|
socket = await connectSocket();
|
|
|
|
|
if (socket == null) throw Exception("Unsuccessful socket connection");
|
2026-01-05 20:55:37 -06:00
|
|
|
|
|
|
|
|
// Handle rate limiting errors
|
|
|
|
|
socket?.on("error", (data) async {
|
|
|
|
|
if (data is Map<String, dynamic>) {
|
|
|
|
|
if (data['code'] == 429) {
|
|
|
|
|
// Rate limited - wait and reconnect
|
|
|
|
|
print("Rate limited: ${data['message']}. Reconnecting in 1 second...");
|
|
|
|
|
socket?.disconnect();
|
|
|
|
|
socket?.dispose();
|
|
|
|
|
await Future.delayed(const Duration(seconds: 1));
|
|
|
|
|
if (mounted) {
|
|
|
|
|
initSocket();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-07-10 18:52:04 -05:00
|
|
|
socket?.on("success", (_) {
|
2025-12-28 14:07:04 -06:00
|
|
|
socket?.on("device_connected", (data) {
|
|
|
|
|
if (data is Map<String, dynamic>) {
|
|
|
|
|
if (data['deviceID'] == widget.deviceId) {
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setState(() {
|
|
|
|
|
deviceConnected = true;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
socket?.on("device_disconnected", (data) {
|
|
|
|
|
if (data is Map<String, dynamic>) {
|
|
|
|
|
if (data['deviceID'] == widget.deviceId) {
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setState(() {
|
|
|
|
|
deviceConnected = false;
|
|
|
|
|
// Reset calibration if it was in progress
|
|
|
|
|
if (calibrating) {
|
|
|
|
|
calibrationStage = 0;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-07-10 18:52:04 -05:00
|
|
|
socket?.on("posUpdates", (list) {
|
|
|
|
|
for (var update in list) {
|
|
|
|
|
if (update is Map<String, dynamic>) {
|
|
|
|
|
if (update['periphID'] == widget.peripheralId) {
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setState(() {
|
|
|
|
|
_blindPosition = (update['pos'] as int).toDouble();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-12-28 14:07:04 -06:00
|
|
|
|
2026-03-09 02:31:49 -05:00
|
|
|
socket?.on("battery_alert", (data) {
|
|
|
|
|
if (data is! Map<String, dynamic>) return;
|
|
|
|
|
if (data['deviceId'] != widget.deviceId) return;
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
final type = data['type'] as String? ?? '';
|
|
|
|
|
final soc = data['soc'] as int? ?? 0;
|
2026-03-21 00:00:35 -05:00
|
|
|
setState(() => batterySoc = soc);
|
2026-03-09 02:31:49 -05:00
|
|
|
final (String message, Color color) = switch (type) {
|
|
|
|
|
'overvoltage' => ('Battery fault detected. Please check your charger.', Colors.red),
|
|
|
|
|
'critical_low' => ('Battery critically low ($soc%). Device shutting down.', Colors.red),
|
|
|
|
|
'low_voltage_warning' => ('Battery voltage dip detected ($soc%). Monitor closely.', Colors.orange),
|
|
|
|
|
'low_20' => ('Battery low: $soc% remaining. Consider charging soon.', Colors.orange),
|
|
|
|
|
'low_10' => ('Battery very low: $soc% remaining. Charge now.', Colors.deepOrange),
|
|
|
|
|
_ => ('Battery alert received ($soc%).', Colors.orange),
|
|
|
|
|
};
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
|
|
|
|
content: Text(message),
|
|
|
|
|
backgroundColor: color,
|
|
|
|
|
duration: const Duration(seconds: 6),
|
|
|
|
|
));
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-18 01:51:55 -05:00
|
|
|
// Server emits this when the device reports its own calibration state.
|
|
|
|
|
// When calibrated=false, reset all calibration UI state so the
|
|
|
|
|
// pre-calibration screen is shown and the user can tap Calibrate.
|
|
|
|
|
socket?.on("calib_status_changed", (data) {
|
|
|
|
|
if (data is Map<String, dynamic>) {
|
|
|
|
|
if (data['periphID'] == widget.peripheralId) {
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
if (data['calibrated'] == false) {
|
2026-03-21 00:12:47 -05:00
|
|
|
final wasAwaiting = _awaitingDeviceWake;
|
2026-03-18 01:51:55 -05:00
|
|
|
setState(() {
|
|
|
|
|
calibrated = false;
|
|
|
|
|
calibrating = false;
|
|
|
|
|
calibrationStage = 0;
|
2026-03-21 00:12:47 -05:00
|
|
|
_awaitingDeviceWake = false;
|
2026-03-18 01:51:55 -05:00
|
|
|
});
|
2026-03-21 00:12:47 -05:00
|
|
|
if (wasAwaiting) calibrate();
|
2026-03-18 01:51:55 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
socket?.on("device_pos_report", (data) {
|
|
|
|
|
if (data is Map<String, dynamic>) {
|
|
|
|
|
if (data['periphID'] == widget.peripheralId) {
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setState(() {
|
|
|
|
|
_blindPosition = (data['pos'] as int).toDouble();
|
2026-03-21 00:00:35 -05:00
|
|
|
_movementPending = false;
|
2026-03-18 01:51:55 -05:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-07-10 18:52:04 -05:00
|
|
|
socket?.on("calib", (periphData) {
|
|
|
|
|
if (periphData is Map<String, dynamic>) {
|
|
|
|
|
if (periphData['periphID'] == widget.peripheralId) {
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setState(() {
|
|
|
|
|
calibrating = true;
|
|
|
|
|
calibrated = false;
|
2025-12-28 14:07:04 -06:00
|
|
|
calibrationStage = 0; // Waiting for device to be ready
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
socket?.on("calib_error", (errorData) {
|
|
|
|
|
if (errorData is Map<String, dynamic>) {
|
|
|
|
|
if (errorData['periphID'] == widget.peripheralId) {
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setState(() {
|
|
|
|
|
calibrating = false;
|
|
|
|
|
calibrationStage = 0;
|
2025-07-10 18:52:04 -05:00
|
|
|
});
|
2025-12-28 14:07:04 -06:00
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
SnackBar(
|
|
|
|
|
content: Text(errorData['message'] ?? 'Calibration error'),
|
|
|
|
|
backgroundColor: Colors.red,
|
|
|
|
|
)
|
|
|
|
|
);
|
2025-07-10 18:52:04 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-12-28 14:07:04 -06:00
|
|
|
|
|
|
|
|
socket?.on("calib_stage1_ready", (periphData) {
|
|
|
|
|
if (periphData is Map<String, dynamic>) {
|
|
|
|
|
if (periphData['periphID'] == widget.peripheralId) {
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setState(() {
|
|
|
|
|
calibrationStage = 1; // Device ready for tilt up
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
socket?.on("calib_stage2_ready", (periphData) {
|
|
|
|
|
if (periphData is Map<String, dynamic>) {
|
|
|
|
|
if (periphData['periphID'] == widget.peripheralId) {
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setState(() {
|
|
|
|
|
calibrationStage = 2; // Device ready for tilt down
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-07-10 18:52:04 -05:00
|
|
|
socket?.on("calib_done", (periphData) {
|
|
|
|
|
if (periphData is Map<String, dynamic>) {
|
|
|
|
|
if (periphData['periphID'] == widget.peripheralId) {
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setState(() {
|
|
|
|
|
calibrating = false;
|
|
|
|
|
calibrated = true;
|
2025-12-28 14:07:04 -06:00
|
|
|
calibrationStage = 0;
|
2025-07-10 18:52:04 -05:00
|
|
|
});
|
2026-01-02 11:24:55 -06:00
|
|
|
// Fetch updated peripheral data after calibration completes
|
|
|
|
|
fetchState();
|
2025-07-10 18:52:04 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(errorSnackbar(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> calibrate() async {
|
2025-12-28 14:07:04 -06:00
|
|
|
if (!deviceConnected) {
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
const SnackBar(
|
|
|
|
|
content: Text('Device must be connected to calibrate'),
|
|
|
|
|
backgroundColor: Colors.orange,
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-10 18:52:04 -05:00
|
|
|
try {
|
|
|
|
|
final payload = {
|
|
|
|
|
'periphId': widget.peripheralId
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
final response = await securePost(payload, 'calib');
|
|
|
|
|
|
|
|
|
|
if (response == null) throw Exception("auth error");
|
|
|
|
|
if (response.statusCode != 202) throw Exception("Server Error");
|
2026-01-02 11:24:55 -06:00
|
|
|
calibrated = false;
|
2025-07-10 18:52:04 -05:00
|
|
|
} catch (e) {
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(errorSnackbar(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> cancelCalib() async {
|
|
|
|
|
try {
|
|
|
|
|
final payload = {
|
|
|
|
|
'periphId': widget.peripheralId
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
final response = await securePost(payload, 'cancel_calib');
|
|
|
|
|
|
|
|
|
|
if (response == null) throw Exception("auth error");
|
|
|
|
|
if (response.statusCode != 202) throw Exception("Server Error");
|
2025-12-28 14:07:04 -06:00
|
|
|
|
|
|
|
|
// Only update state if cancel succeeded
|
|
|
|
|
if (!mounted) return;
|
2025-07-10 18:52:04 -05:00
|
|
|
setState(() {
|
|
|
|
|
calibrated = false;
|
|
|
|
|
calibrating = false;
|
2025-12-28 14:07:04 -06:00
|
|
|
calibrationStage = 0;
|
2025-07-10 18:52:04 -05:00
|
|
|
});
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(errorSnackbar(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-28 14:07:04 -06:00
|
|
|
Future<void> completeStage1() async {
|
|
|
|
|
// User confirms they've tilted blinds all the way up
|
|
|
|
|
// Tell device to proceed to stage 2
|
|
|
|
|
socket?.emit("user_stage1_complete", {
|
|
|
|
|
"periphID": widget.peripheralId,
|
|
|
|
|
"periphNum": widget.peripheralNum,
|
|
|
|
|
"deviceID": widget.deviceId
|
|
|
|
|
});
|
|
|
|
|
setState(() {
|
|
|
|
|
calibrationStage = 0; // Wait for device acknowledgment
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> completeStage2() async {
|
|
|
|
|
// User confirms they've tilted blinds all the way down
|
|
|
|
|
// Tell device calibration is complete
|
|
|
|
|
socket?.emit("user_stage2_complete", {
|
|
|
|
|
"periphID": widget.peripheralId,
|
|
|
|
|
"periphNum": widget.peripheralNum,
|
|
|
|
|
"deviceID": widget.deviceId
|
|
|
|
|
});
|
|
|
|
|
setState(() {
|
|
|
|
|
calibrationStage = 0; // Wait for device acknowledgment
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-10 18:52:04 -05:00
|
|
|
Future<void> getName() async {
|
|
|
|
|
try {
|
|
|
|
|
final payload = {
|
|
|
|
|
'periphId': widget.peripheralId
|
|
|
|
|
};
|
|
|
|
|
final response = await secureGet('peripheral_name', queryParameters: payload);
|
|
|
|
|
if (response == null) throw Exception("auth error");
|
|
|
|
|
if (response.statusCode != 200) throw Exception("Server Error");
|
|
|
|
|
final body = json.decode(response.body);
|
|
|
|
|
setState(() => peripheralName = body['name']);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(errorSnackbar(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-21 00:00:35 -05:00
|
|
|
Future<void> getDeviceBatterySoc() async {
|
|
|
|
|
try {
|
|
|
|
|
final payload = {'deviceId': widget.deviceId};
|
|
|
|
|
final response = await secureGet('device_name', queryParameters: payload);
|
|
|
|
|
if (response == null) throw Exception("auth error");
|
|
|
|
|
if (response.statusCode != 200) throw Exception("Server Error");
|
|
|
|
|
final body = json.decode(response.body);
|
|
|
|
|
if (!mounted) return;
|
2026-03-21 20:30:37 -05:00
|
|
|
setState(() {
|
|
|
|
|
batterySoc = body['battery_soc'] as int?;
|
|
|
|
|
_deviceTimezone = body['timezone'] as String?;
|
|
|
|
|
});
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// Non-critical; swallow silently
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> updateDeviceTimezone(String timezone) async {
|
|
|
|
|
try {
|
|
|
|
|
final response = await securePost(
|
|
|
|
|
{'deviceId': widget.deviceId, 'timezone': timezone},
|
|
|
|
|
'update_device_timezone',
|
|
|
|
|
);
|
|
|
|
|
if (response == null) throw Exception("Auth Error");
|
|
|
|
|
if (response.statusCode != 200) throw Exception("Server Error");
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setState(() => _deviceTimezone = timezone);
|
2026-03-21 00:00:35 -05:00
|
|
|
} catch (e) {
|
2026-03-21 20:30:37 -05:00
|
|
|
if (!mounted) return;
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(errorSnackbar(e));
|
2026-03-21 00:00:35 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-28 14:07:04 -06:00
|
|
|
Future<void> checkDeviceConnection() async {
|
|
|
|
|
try {
|
|
|
|
|
final payload = {
|
|
|
|
|
'deviceId': widget.deviceId
|
|
|
|
|
};
|
|
|
|
|
final response = await secureGet('device_connection_status', queryParameters: payload);
|
|
|
|
|
if (response == null) throw Exception("auth error");
|
|
|
|
|
if (response.statusCode != 200) throw Exception("Server Error");
|
|
|
|
|
final body = json.decode(response.body);
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setState(() => deviceConnected = body['connected']);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(errorSnackbar(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-02 11:24:55 -06:00
|
|
|
Future fetchState() async{
|
2025-07-10 18:52:04 -05:00
|
|
|
try {
|
|
|
|
|
final payload = {
|
|
|
|
|
'periphId': widget.peripheralId
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
final response = await secureGet('peripheral_status', queryParameters: payload);
|
|
|
|
|
if (response == null) throw Exception("auth error");
|
|
|
|
|
if (response.statusCode != 200) {
|
|
|
|
|
if (response.statusCode == 404) throw Exception("Device Not Found");
|
|
|
|
|
throw Exception("Server Error");
|
|
|
|
|
}
|
|
|
|
|
final body = json.decode(response.body) as Map<String, dynamic>;
|
|
|
|
|
if (!body['await_calib']){
|
|
|
|
|
if (!body['calibrated']) {
|
|
|
|
|
calibrated = false;
|
|
|
|
|
calibrating = false;
|
|
|
|
|
}
|
|
|
|
|
else {
|
|
|
|
|
getImage();
|
2026-01-01 12:53:20 -06:00
|
|
|
|
|
|
|
|
if (body['last_set'] != null) {
|
|
|
|
|
final nowUtc = DateTime.now().toUtc();
|
|
|
|
|
final lastSetUtc = DateTime.parse(body['last_set']);
|
|
|
|
|
final Duration difference = nowUtc.difference(lastSetUtc);
|
|
|
|
|
if (!lastSetUtc.isUtc) throw Exception("Why isn't the server giving UTC?");
|
|
|
|
|
final diffDays = difference.inDays > 0;
|
|
|
|
|
final diffHours = difference.inHours > 0;
|
|
|
|
|
final diffMins = difference.inMinutes > 0;
|
|
|
|
|
lastSetMessage = "Last set ${diffDays ? '${difference.inDays.toString()} days' : diffHours ? '${difference.inHours.toString()} hours' : diffMins ? '${difference.inMinutes.toString()} minutes' : '${difference.inSeconds.toString()} seconds'} ago";
|
|
|
|
|
} else {
|
|
|
|
|
lastSetMessage = "Never set";
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-10 18:52:04 -05:00
|
|
|
_blindPosition = (body['last_pos'] as int).toDouble();
|
|
|
|
|
|
|
|
|
|
calibrated = true;
|
|
|
|
|
calibrating = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
else {
|
|
|
|
|
calibrating = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setState(() {loaded = true;});
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(errorSnackbar(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future initAll() async{
|
|
|
|
|
getName();
|
2025-12-28 14:07:04 -06:00
|
|
|
checkDeviceConnection();
|
2026-01-02 11:24:55 -06:00
|
|
|
fetchState();
|
2026-03-21 00:00:35 -05:00
|
|
|
getDeviceBatterySoc();
|
2025-07-10 18:52:04 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void rename() {
|
2026-03-21 20:30:37 -05:00
|
|
|
String? dialogTimezone = _deviceTimezone;
|
2025-07-10 18:52:04 -05:00
|
|
|
showDialog(
|
|
|
|
|
context: context,
|
|
|
|
|
builder: (BuildContext dialogContext) {
|
2026-03-21 20:30:37 -05:00
|
|
|
return StatefulBuilder(
|
|
|
|
|
builder: (context, setDialogState) {
|
|
|
|
|
return AlertDialog(
|
|
|
|
|
title: Text(
|
|
|
|
|
widget.isSinglePort ? "Rename Device" : "Rename Peripheral",
|
|
|
|
|
style: GoogleFonts.aBeeZee(),
|
|
|
|
|
),
|
|
|
|
|
content: Column(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
children: [
|
|
|
|
|
BlindMasterMainInput(
|
|
|
|
|
widget.isSinglePort
|
|
|
|
|
? "New Device Name (blank to keep current)"
|
|
|
|
|
: "New Peripheral Name (blank to keep current)",
|
|
|
|
|
controller: _peripheralRenameController,
|
|
|
|
|
),
|
|
|
|
|
if (widget.isSinglePort) ...[
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
TimezonePicker(
|
|
|
|
|
value: dialogTimezone,
|
|
|
|
|
label: 'Device Timezone (blank to keep current)',
|
|
|
|
|
onChanged: (tz) => setDialogState(() => dialogTimezone = tz),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
actions: [
|
|
|
|
|
Row(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
|
|
|
children: [
|
|
|
|
|
ElevatedButton(
|
|
|
|
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
|
|
|
|
child: const Text(
|
|
|
|
|
"Cancel",
|
|
|
|
|
style: TextStyle(color: Colors.red),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
ElevatedButton(
|
|
|
|
|
onPressed: () {
|
|
|
|
|
final newName = _peripheralRenameController.text.trim();
|
|
|
|
|
if (widget.isSinglePort) {
|
|
|
|
|
if (newName.isNotEmpty) updateDeviceName(newName);
|
|
|
|
|
if (dialogTimezone != null) updateDeviceTimezone(dialogTimezone!);
|
|
|
|
|
} else {
|
|
|
|
|
if (newName.isNotEmpty) updatePeriphName(newName, widget.peripheralId);
|
|
|
|
|
}
|
|
|
|
|
Navigator.of(dialogContext).pop();
|
|
|
|
|
},
|
|
|
|
|
child: const Text("Confirm"),
|
|
|
|
|
),
|
|
|
|
|
],
|
2025-07-10 18:52:04 -05:00
|
|
|
),
|
|
|
|
|
],
|
2026-03-21 20:30:37 -05:00
|
|
|
);
|
|
|
|
|
},
|
2025-07-10 18:52:04 -05:00
|
|
|
);
|
2026-03-21 20:30:37 -05:00
|
|
|
},
|
2025-07-10 18:52:04 -05:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-21 00:47:21 -05:00
|
|
|
Future updateDeviceName(String name) async {
|
|
|
|
|
try {
|
|
|
|
|
if (name.isEmpty) throw Exception("New name cannot be empty!");
|
|
|
|
|
final payload = {
|
|
|
|
|
'deviceId': widget.deviceId,
|
|
|
|
|
'newName': name,
|
|
|
|
|
};
|
|
|
|
|
final response = await securePost(payload, 'rename_device');
|
|
|
|
|
if (response == null) throw Exception("Auth Error");
|
|
|
|
|
if (response.statusCode != 204) {
|
|
|
|
|
if (response.statusCode == 409) throw Exception("Choose a unique name!");
|
|
|
|
|
throw Exception("Server Error");
|
|
|
|
|
}
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setState(() => _deviceName = name);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(errorSnackbar(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-10 18:52:04 -05:00
|
|
|
Future updatePeriphName(String name, int id) async {
|
|
|
|
|
try {
|
|
|
|
|
if (name.isEmpty) throw Exception("New name cannot be empty!");
|
|
|
|
|
final payload = {
|
|
|
|
|
'periphId': id,
|
|
|
|
|
'newName': name,
|
|
|
|
|
};
|
|
|
|
|
final response = await securePost(payload, 'rename_peripheral');
|
|
|
|
|
if (response == null) throw Exception("Auth Error");
|
|
|
|
|
if (response.statusCode != 204) {
|
|
|
|
|
if (response.statusCode == 409) throw Exception("Choose a unique name!");
|
|
|
|
|
throw Exception("Server Error");
|
|
|
|
|
}
|
|
|
|
|
getName();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(errorSnackbar(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void recalibrate() {
|
|
|
|
|
showDialog(
|
|
|
|
|
context: context,
|
|
|
|
|
builder: (BuildContext dialogContext) {
|
|
|
|
|
return AlertDialog(
|
|
|
|
|
title: Text(
|
|
|
|
|
"Recalibrate Peripheral",
|
|
|
|
|
style: GoogleFonts.aBeeZee(),
|
|
|
|
|
),
|
|
|
|
|
content: const Text(
|
2026-03-21 00:12:47 -05:00
|
|
|
"After confirming, wake the device by moving the wand. Calibration will start automatically.",
|
2025-07-10 18:52:04 -05:00
|
|
|
textAlign: TextAlign.center,
|
|
|
|
|
),
|
|
|
|
|
actions: [
|
|
|
|
|
Row(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
|
|
|
children: [
|
|
|
|
|
ElevatedButton(
|
|
|
|
|
onPressed: () {
|
|
|
|
|
Navigator.of(dialogContext).pop();
|
|
|
|
|
},
|
|
|
|
|
child: const Text(
|
|
|
|
|
"Cancel",
|
2026-03-21 00:12:47 -05:00
|
|
|
style: TextStyle(color: Colors.red),
|
2025-07-10 18:52:04 -05:00
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
ElevatedButton(
|
|
|
|
|
onPressed: () {
|
|
|
|
|
Navigator.of(dialogContext).pop();
|
2026-03-21 00:12:47 -05:00
|
|
|
socket?.emit("recalibrate", {
|
|
|
|
|
"periphID": widget.peripheralId,
|
|
|
|
|
"periphNum": widget.peripheralNum,
|
|
|
|
|
});
|
|
|
|
|
setState(() => _awaitingDeviceWake = true);
|
2025-07-10 18:52:04 -05:00
|
|
|
},
|
|
|
|
|
child: const Text("Confirm")
|
|
|
|
|
)
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future updateBlindPosition() async {
|
|
|
|
|
try {
|
|
|
|
|
final payload = {
|
|
|
|
|
'periphId': widget.peripheralId,
|
|
|
|
|
'periphNum': widget.peripheralNum,
|
|
|
|
|
'deviceId': widget.deviceId,
|
|
|
|
|
'newPos': _blindPosition.toInt(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
final response = await securePost(payload, 'manual_position_update');
|
|
|
|
|
if (response == null) throw Exception("Auth Error");
|
|
|
|
|
if (response.statusCode != 202) {
|
|
|
|
|
throw Exception("Server Error");
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(errorSnackbar(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
return Scaffold(
|
2026-03-21 00:47:21 -05:00
|
|
|
resizeToAvoidBottomInset: false,
|
2025-07-10 18:52:04 -05:00
|
|
|
appBar: AppBar(
|
|
|
|
|
title: Text(
|
2026-03-21 00:47:21 -05:00
|
|
|
widget.isSinglePort ? _deviceName : "$_deviceName - $peripheralName",
|
2025-07-10 18:52:04 -05:00
|
|
|
style: GoogleFonts.aBeeZee(),
|
|
|
|
|
),
|
|
|
|
|
backgroundColor: Theme.of(context).primaryColorLight,
|
|
|
|
|
foregroundColor: Colors.white,
|
2026-03-21 00:00:35 -05:00
|
|
|
actions: [
|
|
|
|
|
if (batterySoc != null)
|
|
|
|
|
Padding(
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
|
|
|
child: Row(
|
|
|
|
|
children: [
|
|
|
|
|
Icon(
|
|
|
|
|
batterySoc! <= 10 ? Icons.battery_alert
|
|
|
|
|
: batterySoc! <= 20 ? Icons.battery_1_bar
|
|
|
|
|
: batterySoc! <= 40 ? Icons.battery_2_bar
|
|
|
|
|
: batterySoc! <= 60 ? Icons.battery_3_bar
|
|
|
|
|
: batterySoc! <= 80 ? Icons.battery_5_bar
|
|
|
|
|
: Icons.battery_full,
|
|
|
|
|
color: batterySoc! <= 10 ? Colors.red : Colors.white,
|
2025-12-28 14:07:04 -06:00
|
|
|
),
|
2026-03-21 00:00:35 -05:00
|
|
|
const SizedBox(width: 4),
|
|
|
|
|
Text('$batterySoc%', style: const TextStyle(color: Colors.white)),
|
|
|
|
|
],
|
|
|
|
|
),
|
2025-12-28 14:07:04 -06:00
|
|
|
),
|
2026-03-21 00:00:35 -05:00
|
|
|
],
|
2025-07-10 18:52:04 -05:00
|
|
|
),
|
|
|
|
|
|
2026-03-21 00:00:35 -05:00
|
|
|
body: Column(
|
|
|
|
|
children: [
|
|
|
|
|
if (_movementPending)
|
|
|
|
|
Container(
|
|
|
|
|
width: double.infinity,
|
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
|
|
|
|
color: Colors.orange.shade700,
|
|
|
|
|
child: Row(
|
|
|
|
|
children: [
|
|
|
|
|
const Icon(Icons.access_time, color: Colors.white, size: 20),
|
|
|
|
|
const SizedBox(width: 12),
|
|
|
|
|
const Expanded(
|
|
|
|
|
child: Text(
|
|
|
|
|
'Movement pending... Will take up to 1 minute',
|
|
|
|
|
style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-03-21 00:12:47 -05:00
|
|
|
Expanded(child: _awaitingDeviceWake
|
|
|
|
|
? Center(
|
|
|
|
|
child: Column(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
|
children: [
|
|
|
|
|
CircularProgressIndicator(color: Theme.of(context).primaryColorLight),
|
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
|
Text(
|
|
|
|
|
"Wake the device by moving the wand",
|
|
|
|
|
style: GoogleFonts.aBeeZee(fontSize: 16),
|
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
Text(
|
|
|
|
|
"Calibration will start automatically",
|
|
|
|
|
style: GoogleFonts.aBeeZee(fontSize: 13, color: Colors.grey),
|
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
|
TextButton(
|
|
|
|
|
onPressed: () => setState(() => _awaitingDeviceWake = false),
|
|
|
|
|
child: const Text("Cancel", style: TextStyle(color: Colors.red)),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
: loaded
|
2025-07-10 18:52:04 -05:00
|
|
|
? (calibrating
|
|
|
|
|
? RefreshIndicator(
|
|
|
|
|
onRefresh: initAll,
|
|
|
|
|
child: SingleChildScrollView(
|
|
|
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
|
|
|
child: SizedBox(
|
|
|
|
|
height: MediaQuery.of(context).size.height * 0.8,
|
|
|
|
|
child: Center(
|
|
|
|
|
child: Column(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
|
children: [
|
|
|
|
|
Container(
|
|
|
|
|
padding: EdgeInsets.all(20),
|
|
|
|
|
child: Text(
|
2025-12-28 14:07:04 -06:00
|
|
|
calibrationStage == 0
|
|
|
|
|
? "Preparing device for calibration..."
|
|
|
|
|
: calibrationStage == 1
|
|
|
|
|
? "Tilt the blinds ALL THE WAY UP"
|
|
|
|
|
: "Tilt the blinds ALL THE WAY DOWN",
|
|
|
|
|
style: GoogleFonts.aBeeZee(
|
|
|
|
|
fontSize: 20,
|
|
|
|
|
fontWeight: FontWeight.bold
|
|
|
|
|
),
|
|
|
|
|
textAlign: TextAlign.center,
|
2025-07-10 18:52:04 -05:00
|
|
|
),
|
|
|
|
|
),
|
2025-12-28 14:07:04 -06:00
|
|
|
if (calibrationStage == 0)
|
|
|
|
|
CircularProgressIndicator(
|
|
|
|
|
color: Theme.of(context).primaryColorLight,
|
|
|
|
|
),
|
|
|
|
|
SizedBox(height: 20),
|
|
|
|
|
if (calibrationStage == 1 || calibrationStage == 2)
|
|
|
|
|
Row(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
|
children: [
|
|
|
|
|
ElevatedButton(
|
|
|
|
|
onPressed: calibrationStage == 1 ? completeStage1 : completeStage2,
|
|
|
|
|
style: ElevatedButton.styleFrom(
|
|
|
|
|
backgroundColor: Colors.green,
|
|
|
|
|
foregroundColor: Colors.white,
|
|
|
|
|
padding: EdgeInsets.symmetric(horizontal: 30, vertical: 15),
|
|
|
|
|
),
|
|
|
|
|
child: const Text(
|
|
|
|
|
"Complete",
|
|
|
|
|
style: TextStyle(fontSize: 16),
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
SizedBox(width: 20),
|
|
|
|
|
ElevatedButton(
|
|
|
|
|
onPressed: cancelCalib,
|
|
|
|
|
style: ElevatedButton.styleFrom(
|
|
|
|
|
backgroundColor: Colors.red,
|
|
|
|
|
foregroundColor: Colors.white,
|
|
|
|
|
padding: EdgeInsets.symmetric(horizontal: 30, vertical: 15),
|
|
|
|
|
),
|
|
|
|
|
child: const Text(
|
|
|
|
|
"Cancel",
|
|
|
|
|
style: TextStyle(fontSize: 16),
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
else
|
|
|
|
|
ElevatedButton(
|
|
|
|
|
onPressed: cancelCalib,
|
|
|
|
|
child: const Text(
|
|
|
|
|
"Cancel",
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
color: Colors.red
|
|
|
|
|
),
|
|
|
|
|
)
|
2025-07-10 18:52:04 -05:00
|
|
|
)
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
: (calibrated
|
|
|
|
|
? Column(
|
|
|
|
|
children: [
|
2026-01-02 19:33:24 -06:00
|
|
|
BlindControlWidget(
|
|
|
|
|
imagePath: imagePath,
|
|
|
|
|
blindPosition: _blindPosition,
|
|
|
|
|
onPositionChanged: (value) {
|
|
|
|
|
setState(() {
|
|
|
|
|
_blindPosition = value;
|
2026-03-21 00:00:35 -05:00
|
|
|
_movementPending = true;
|
2026-01-02 19:33:24 -06:00
|
|
|
});
|
2026-03-21 00:00:35 -05:00
|
|
|
updateBlindPosition();
|
2026-01-02 19:33:24 -06:00
|
|
|
},
|
2025-07-10 18:52:04 -05:00
|
|
|
),
|
|
|
|
|
Container(
|
|
|
|
|
padding: EdgeInsets.all(25),
|
|
|
|
|
child: Text(
|
|
|
|
|
lastSetMessage
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
Container(
|
|
|
|
|
padding: EdgeInsets.all(10),
|
|
|
|
|
child: ElevatedButton(
|
|
|
|
|
onPressed: () {
|
|
|
|
|
Navigator.push(
|
|
|
|
|
context,
|
|
|
|
|
MaterialPageRoute(
|
2025-12-22 20:26:33 -06:00
|
|
|
builder: (context) => SchedulesScreen(peripheralId: widget.peripheralId, periphName: peripheralName,
|
|
|
|
|
deviceId: widget.deviceId, peripheralNum: widget.peripheralNum, deviceName: widget.deviceName,)
|
2025-07-10 18:52:04 -05:00
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
child: Text(
|
|
|
|
|
"Set Schedules"
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
: SizedBox(
|
|
|
|
|
height: MediaQuery.of(context).size.height * 0.8,
|
|
|
|
|
child: Center(
|
|
|
|
|
child: Column(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
|
children: [
|
|
|
|
|
Container(
|
|
|
|
|
padding: EdgeInsets.all(20),
|
|
|
|
|
child: Text(
|
|
|
|
|
"Peripheral Not Calibrated"
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
ElevatedButton(
|
2025-12-28 14:07:04 -06:00
|
|
|
onPressed: deviceConnected ? calibrate : null,
|
|
|
|
|
style: ElevatedButton.styleFrom(
|
|
|
|
|
backgroundColor: deviceConnected ? null : Colors.grey,
|
|
|
|
|
),
|
2025-07-10 18:52:04 -05:00
|
|
|
child: const Text("Calibrate")
|
|
|
|
|
)
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
)))
|
2026-03-21 00:00:35 -05:00
|
|
|
: BlindmasterProgressIndicator()),
|
|
|
|
|
],
|
|
|
|
|
),
|
2025-07-10 18:52:04 -05:00
|
|
|
floatingActionButton: Container(
|
|
|
|
|
padding: EdgeInsets.all(25),
|
|
|
|
|
child: Row(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
|
|
children: [
|
|
|
|
|
FloatingActionButton(
|
|
|
|
|
heroTag: "rename",
|
|
|
|
|
tooltip: "Rename Peripheral",
|
|
|
|
|
onPressed: rename,
|
|
|
|
|
foregroundColor: Theme.of(context).highlightColor,
|
|
|
|
|
backgroundColor: Theme.of(context).primaryColorDark,
|
|
|
|
|
child: Icon(Icons.drive_file_rename_outline_sharp),
|
|
|
|
|
),
|
|
|
|
|
FloatingActionButton(
|
|
|
|
|
heroTag: "recalibrate",
|
|
|
|
|
tooltip: "Recalibrate Peripheral",
|
2026-03-21 00:12:47 -05:00
|
|
|
onPressed: recalibrate,
|
|
|
|
|
foregroundColor: Theme.of(context).highlightColor,
|
|
|
|
|
backgroundColor: Theme.of(context).primaryColorDark,
|
2025-07-10 18:52:04 -05:00
|
|
|
child: Icon(Icons.swap_vert),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|