finished schedules screens

This commit is contained in:
2026-01-01 12:53:20 -06:00
parent 00c54159d4
commit 331d926c2b
5 changed files with 282 additions and 68 deletions

View File

@@ -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,

View File

@@ -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<String, dynamic>? existingSchedule;
final String? scheduleId;
bool get isEditing => existingSchedule != null && scheduleId != null;
@override
State<DayTimePicker> createState() => _DayTimePickerState();
}
@@ -16,10 +35,25 @@ class _DayTimePickerState extends State<DayTimePicker> {
double _blindPosition = 0;
String imagePath = "";
Set<DaysOfWeek> days = <DaysOfWeek>{};
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<DayTimePicker> {
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<DayTimePicker> {
setState(() {
if (selected) {
days.add(day);
showError = false;
} else {
days.remove(day);
}
@@ -179,6 +222,18 @@ class _DayTimePickerState extends State<DayTimePicker> {
);
}).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<DayTimePicker> {
),
)
),
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")
)
]
)
],

View File

@@ -307,14 +307,20 @@ class _PeripheralScreenState extends State<PeripheralScreen> {
}
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<PeripheralScreen> {
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),
);
}),
),
);
}
)
)
],

View File

@@ -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<SchedulesScreen> {
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<SchedulesScreen> {
),
);
},
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<SchedulesScreen> {
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

View File

@@ -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: