diff --git a/lib/BlindMasterResources/error_snackbar.dart b/lib/BlindMasterResources/error_snackbar.dart index 437a439..b324597 100644 --- a/lib/BlindMasterResources/error_snackbar.dart +++ b/lib/BlindMasterResources/error_snackbar.dart @@ -5,10 +5,16 @@ SnackBar errorSnackbar( Color backgroundColor = const Color.fromARGB(255, 196, 26, 14), Duration duration = const Duration(seconds: 3), }) { + final errorText = e is String + ? e + : (e.toString().contains(':') + ? e.toString().substring(e.toString().indexOf(':') + 1).trim() + : e.toString()); + return SnackBar( backgroundColor: Color.fromARGB(255, 196, 26, 14), content: Text( - e.toString().replaceFirst(RegExp(r'^[^:]+:\s*'), ''), + errorText, textAlign: TextAlign.center, style: TextStyle( fontSize: 15, diff --git a/lib/BlindMasterScreens/day_time_picker.dart b/lib/BlindMasterScreens/day_time_picker.dart index 67edf91..6317645 100644 --- a/lib/BlindMasterScreens/day_time_picker.dart +++ b/lib/BlindMasterScreens/day_time_picker.dart @@ -1,12 +1,31 @@ +import 'package:blind_master/BlindMasterResources/error_snackbar.dart'; +import 'package:blind_master/BlindMasterResources/secure_transmissions.dart'; import 'package:blind_master/main.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; class DayTimePicker extends StatefulWidget { - const DayTimePicker({super.key, required this.defaultTime, required this.sendSchedule}); + const DayTimePicker({ + super.key, + required this.defaultTime, + required this.sendSchedule, + required this.peripheralId, + required this.peripheralNum, + required this.deviceId, + this.existingSchedule, + this.scheduleId, + }); final TimeOfDay defaultTime; final void Function(TimeOfDay) sendSchedule; + final int peripheralId; + final int peripheralNum; + final int deviceId; + final Map? existingSchedule; + final String? scheduleId; + + bool get isEditing => existingSchedule != null && scheduleId != null; + @override State createState() => _DayTimePickerState(); } @@ -16,10 +35,25 @@ class _DayTimePickerState extends State { double _blindPosition = 0; String imagePath = ""; Set days = {}; + bool showError = false; @override void initState() { super.initState(); + + // If editing, pre-populate with existing schedule data + if (widget.isEditing && widget.existingSchedule != null) { + final schedule = widget.existingSchedule!; + final hour = schedule['schedule']['hours'][0] as int; + final minute = schedule['schedule']['minutes'][0] as int; + scheduleTime = TimeOfDay(hour: hour, minute: minute); + _blindPosition = (schedule['pos'] as int).toDouble(); + + // Pre-populate days + final daysOfWeek = schedule['schedule']['daysOfWeek'] as List; + days = daysOfWeek.map((d) => DaysOfWeek.values[d as int]).toSet(); + } + updateBackground(); } @@ -84,30 +118,38 @@ class _DayTimePickerState extends State { Align( alignment: Alignment.center, - child: Container( - margin: EdgeInsets.only(top: MediaQuery.of(context).size.width * 0.05), - height: MediaQuery.of(context).size.width * 0.43, - width: MediaQuery.of(context).size.width * 0.45, - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: List.generate(10, (index) { - return AnimatedContainer( - duration: const Duration(milliseconds: 300), - height: _blindPosition < 5 ? - 3.65 * (5 - _blindPosition) - : 3.65 * (_blindPosition - 5), - width: MediaQuery.of(context).size.width * 0.40, // example - color: const Color.fromARGB(255, 121, 85, 72), - ); - }), - ), + child: Builder( + builder: (context) { + final containerHeight = MediaQuery.of(context).size.width * 0.43; + final maxSlatHeight = containerHeight / 10; + final slatHeight = _blindPosition < 5 + ? maxSlatHeight * (5 - _blindPosition) / 5 + : maxSlatHeight * (_blindPosition - 5) / 5; + + return Container( + margin: EdgeInsets.only(top: MediaQuery.of(context).size.width * 0.05), + height: containerHeight, + width: MediaQuery.of(context).size.width * 0.45, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate(10, (index) { + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: slatHeight, + width: MediaQuery.of(context).size.width * 0.40, + color: const Color.fromARGB(255, 121, 85, 72), + ); + }), + ), + ); + } ) ), ], ), // Slider on the side - Align( - alignment: Alignment.centerRight, + SizedBox( + width: MediaQuery.of(context).size.width * 0.10, child: RotatedBox( quarterTurns: -1, child: Slider( @@ -171,6 +213,7 @@ class _DayTimePickerState extends State { setState(() { if (selected) { days.add(day); + showError = false; } else { days.remove(day); } @@ -179,6 +222,18 @@ class _DayTimePickerState extends State { ); }).toList(), ), + if (showError) + Container( + padding: EdgeInsets.only(top: 10), + child: Text( + 'Please select at least one day', + style: TextStyle( + color: Colors.red, + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + ), ], ), actions: [ @@ -196,6 +251,78 @@ class _DayTimePickerState extends State { ), ) ), + ElevatedButton( + onPressed: () async { + if (days.isEmpty) { + setState(() { + showError = true; + }); + return; + } + + try { + // Convert DaysOfWeek enum to day numbers (0=Sunday, 1=Monday, etc.) + final daysOfWeek = days.map((day) => day.index).toList(); + + final timeToUse = scheduleTime ?? widget.defaultTime; + + final payload = { + 'periphId': widget.peripheralId, + 'periphNum': widget.peripheralNum, + 'deviceId': widget.deviceId, + 'newPos': _blindPosition.toInt(), + 'time': { + 'hour': timeToUse.hour, + 'minute': timeToUse.minute, + }, + 'daysOfWeek': daysOfWeek, + }; + + // Add jobId if editing + if (widget.isEditing) { + payload['jobId'] = widget.scheduleId!; + } + + final endpoint = widget.isEditing ? 'update_schedule' : 'add_schedule'; + final response = await securePost(payload, endpoint); + + if (response == null) throw Exception("Auth Error"); + + // Handle duplicate schedule (409 Conflict) + if (response.statusCode == 409) { + if (!context.mounted) return; + Navigator.of(context).pop(); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('A schedule already exists at this time for this blind'), + backgroundColor: Colors.orange, + duration: Duration(seconds: 4), + ) + ); + return; + } + + if (response.statusCode != 201 && response.statusCode != 200) { + throw Exception("Server Error"); + } + + if (!context.mounted) return; + Navigator.of(context).pop(); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(widget.isEditing ? 'Schedule updated successfully' : 'Schedule added successfully'), + backgroundColor: Colors.green, + ) + ); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar(errorSnackbar(e)); + } + }, + child: Text(widget.isEditing ? "Update" : "Add") + ) ] ) ], diff --git a/lib/BlindMasterScreens/individualControl/peripheral_screen.dart b/lib/BlindMasterScreens/individualControl/peripheral_screen.dart index 9594b44..9ccc8be 100644 --- a/lib/BlindMasterScreens/individualControl/peripheral_screen.dart +++ b/lib/BlindMasterScreens/individualControl/peripheral_screen.dart @@ -307,14 +307,20 @@ class _PeripheralScreenState extends State { } else { getImage(); - 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"; + + 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"; + } + _blindPosition = (body['last_pos'] as int).toDouble(); calibrated = true; @@ -607,23 +613,31 @@ class _PeripheralScreenState extends State { Align( alignment: Alignment.center, - child: Container( - margin: EdgeInsets.only(top: MediaQuery.of(context).size.width * 0.05), - height: MediaQuery.of(context).size.width * 0.68, - width: MediaQuery.of(context).size.width * 0.7, - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: List.generate(10, (index) { - return AnimatedContainer( - duration: const Duration(milliseconds: 300), - height: _blindPosition < 5 ? - 5.4 * (5 - _blindPosition) - : 5.4 * (_blindPosition - 5), - width: MediaQuery.of(context).size.width * 0.65, // example - color: const Color.fromARGB(255, 121, 85, 72), - ); - }), - ), + child: LayoutBuilder( + builder: (context, constraints) { + final containerHeight = MediaQuery.of(context).size.width * 0.68; + final maxSlatHeight = containerHeight / 10; + final slatHeight = _blindPosition < 5 + ? maxSlatHeight * (5 - _blindPosition) / 5 + : maxSlatHeight * (_blindPosition - 5) / 5; + + return Container( + margin: EdgeInsets.only(top: MediaQuery.of(context).size.width * 0.05), + height: containerHeight, + width: MediaQuery.of(context).size.width * 0.7, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate(10, (index) { + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: slatHeight, + width: MediaQuery.of(context).size.width * 0.65, + color: const Color.fromARGB(255, 121, 85, 72), + ); + }), + ), + ); + } ) ) ], diff --git a/lib/BlindMasterScreens/schedules_screen.dart b/lib/BlindMasterScreens/schedules_screen.dart index 6097060..5a1c090 100644 --- a/lib/BlindMasterScreens/schedules_screen.dart +++ b/lib/BlindMasterScreens/schedules_screen.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:blind_master/BlindMasterResources/error_snackbar.dart'; import 'package:blind_master/BlindMasterResources/secure_transmissions.dart'; import 'package:blind_master/BlindMasterScreens/day_time_picker.dart'; +import 'package:blind_master/main.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -28,10 +29,27 @@ class _SchedulesScreenState extends State { getSchedules(); } + String translate(int pos) { + if (pos < 2) { + return "Close (down)"; + } else if (pos < 5) { + return "Open (down)"; + } + else if (pos == 5) { + return "Open"; + } + else if (pos < 9) { + return "Open (up)"; + } + else { + return "Close (up)"; + } + } + Future getSchedules() async { try{ final payload = { - "periphId": widget.deviceId + "periphId": widget.peripheralId }; final response = await securePost(payload, 'periph_schedule_list'); if (response == null) throw Exception("no response!"); @@ -100,23 +118,66 @@ class _SchedulesScreenState extends State { ), ); }, - onDismissed: (direction) { - // TODO Actually delete the schedule - // deleteDevice(device['id'], i); + onDismissed: (direction) async { + final scheduleId = schedule['id'].toString(); + try { + final payload = {'jobId': scheduleId}; + final response = await securePost(payload, 'delete_schedule'); + + if (response == null) throw Exception("Auth Error"); + if (response.statusCode != 200) { + throw Exception("Failed to delete schedule"); + } + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Schedule deleted successfully'), + backgroundColor: Colors.green, + ) + ); + } catch (e) { + if (mounted) return; + ScaffoldMessenger.of(context).showSnackBar(errorSnackbar(e)); + } finally { + // Refresh the list regardless of success/failure + if (mounted) getSchedules(); + } }, child: Card( - child: ListTile( - leading: const Icon(Icons.blinds), - title: Text("${schedule['pos']} every ${schedule['schedule']['daysOfWeek']} at ${schedule['schedule']['hours']}:${schedule['schedule']['minutes']}"), - trailing: const Icon(Icons.arrow_forward_ios_rounded), - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => Placeholder(), - // TODO open popup for schedule setter. - ), - ).then((_) { getSchedules(); }); + child: Builder( + builder: (context) { + final pos = translate(schedule['pos']); + final days = (schedule['schedule']['daysOfWeek'] as List) + .map((d) => DaysOfWeek.values[d].name) + .join(', '); + final hour24 = schedule['schedule']['hours'][0] as int; + final minute = schedule['schedule']['minutes'][0].toString().padLeft(2, '0'); + final period = hour24 >= 12 ? 'PM' : 'AM'; + final hour12 = hour24 == 0 ? 12 : (hour24 > 12 ? hour24 - 12 : hour24); + + return ListTile( + leading: const Icon(Icons.blinds), + title: Text("$pos at $hour12:$minute $period"), + subtitle: Text(days), + trailing: const Icon(Icons.arrow_forward_ios_rounded), + onTap: () { + showDialog( + context: context, + builder: (BuildContext dialogContext) { + return DayTimePicker( + defaultTime: TimeOfDay(hour: 12, minute: 0), + sendSchedule: sendSchedule, + peripheralId: widget.peripheralId, + peripheralNum: widget.peripheralNum, + deviceId: widget.deviceId, + existingSchedule: schedule, + scheduleId: schedule['id'].toString(), + ); + } + ).then((_) { getSchedules(); }); + }, + ); }, ), ), @@ -136,9 +197,15 @@ class _SchedulesScreenState extends State { showDialog( context: context, builder: (BuildContext dialogContext) { // Use dialogContext for navigation within the dialog - return DayTimePicker(defaultTime: TimeOfDay(hour: 12, minute: 0), sendSchedule: sendSchedule); + return DayTimePicker( + defaultTime: TimeOfDay(hour: 12, minute: 0), + sendSchedule: sendSchedule, + peripheralId: widget.peripheralId, + peripheralNum: widget.peripheralNum, + deviceId: widget.deviceId, + ); } - ); + ).then((_) { getSchedules(); }); } @override diff --git a/pubspec.lock b/pubspec.lock index 2ce6977..d9c5a99 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -340,10 +340,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -505,10 +505,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" typed_data: dependency: transitive description: