diff --git a/frontend2/lib/apis/room_cleaning/room_cleaning_api.dart b/frontend2/lib/apis/room_cleaning/room_cleaning_api.dart new file mode 100644 index 00000000..5bc40bb3 --- /dev/null +++ b/frontend2/lib/apis/room_cleaning/room_cleaning_api.dart @@ -0,0 +1,156 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; +import '../../constants/endpoint.dart'; + +class RoomCleaningApi { + // Dev-only: uses local emulator base URL from endpoint.dart. + static const bool isDev = true; + + Future _getToken() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString('access_token'); + } + + // uncomment this when the backend is deployed + Future> fetchSlots( + String hostelId, String weekDay) async { + + final response = await http.get( + Uri.parse( + "$baseUrl/room-cleaning/slots?hostelId=$hostelId&weekDay=$weekDay"), + headers: { + "Content-Type": "application/json" + }, + ); + + print("STATUS: ${response.statusCode}"); + print("BODY: ${response.body}"); + + if (response.headers['content-type']?.contains('application/json') ?? false) { + final data = json.decode(response.body); + + // Handle both possible formats safely + if (data is List) { + return data; + } else if (data is Map && data['slots'] != null) { + return data['slots']; + } else { + return []; + } + } else { + throw Exception("Non JSON response from slots API"); + } + } + + + // Future> fetchSlots( + // String hostelId, String weekDay) async { + // + // await Future.delayed(Duration(seconds: 1)); + // + // return [ + // { + // "_id": "1", + // "startTime": "2026-02-15T08:00:00.000Z", + // "endTime": "2026-02-15T09:00:00.000Z", + // "availableSlots": 3, + // "maxSlots": 5, + // }, + // { + // "_id": "2", + // "startTime": "2026-02-15T09:00:00.000Z", + // "endTime": "2026-02-15T10:00:00.000Z", + // "availableSlots": 0, + // "maxSlots": 5, + // }, + // { + // "_id": "3", + // "startTime": "2026-02-15T10:00:00.000Z", + // "endTime": "2026-02-15T11:00:00.000Z", + // "availableSlots": 2, + // "maxSlots": 5, + // }, + // ]; + // } + + + + Future> bookSlot( + String slotId, String requestedDate, String notes) async { + + final token = await _getToken(); + print("TOKEN: $token"); + + final response = await http.post( + Uri.parse("$baseUrl/room-cleaning/booking/request"), + headers: { + "Authorization": "Bearer $token", + "Content-Type": "application/json", + if (isDev) "x-dev-guest": "1", + }, + body: json.encode({ + "slotId": slotId, + "requestedDate": requestedDate, + "notes": notes, + }), + ); + + print("STATUS CODE: ${response.statusCode}"); + print("RAW BODY: ${response.body}"); + + if (response.headers['content-type']?.contains('application/json') ?? false) { + return json.decode(response.body); + } else { + throw Exception("Server returned non-JSON response"); + } + } + + + Future> cancelBooking( + String bookingId) async { + final token = await _getToken(); + + final response = await http.post( + Uri.parse( + "$baseUrl/room-cleaning/booking/cancel"), + headers: { + "Authorization": "Bearer $token", + "Content-Type": "application/json", + if (isDev) "x-dev-guest": "1", + }, + body: json.encode({ + "bookingId": bookingId, + }), + ); + + print("STATUS: ${response.statusCode}"); + print("BODY: ${response.body}"); + if (response.headers['content-type']?.contains('application/json') ?? false) { + return json.decode(response.body); + } else { + throw Exception("Server returned non-JSON response"); + } + } + + Future> getMyBookings() async { + final token = await _getToken(); + + final response = await http.get( + Uri.parse( + "$baseUrl/room-cleaning/booking/my"), + headers: { + "Authorization": "Bearer $token", + "Content-Type": "application/json", + if (isDev) "x-dev-guest": "1", + }, + ); + + if (response.headers['content-type']?.contains('application/json') ?? false) { + final data = json.decode(response.body); + return data['bookings'] ?? []; + } else { + throw Exception("Server returned non-JSON response"); + } + } +} diff --git a/frontend2/lib/constants/endpoint.dart b/frontend2/lib/constants/endpoint.dart index 26f23f62..6b6a6f82 100644 --- a/frontend2/lib/constants/endpoint.dart +++ b/frontend2/lib/constants/endpoint.dart @@ -1,4 +1,4 @@ -const String baseUrl = "https://hab.codingclub.in/api"; +const String baseUrl = "http://10.200.242.48:3000/api"; const String authUrl = "https://hab.codingclub.in/api"; class NotificationEndpoints { diff --git a/frontend2/lib/main.dart b/frontend2/lib/main.dart index 64e27d18..b577212f 100644 --- a/frontend2/lib/main.dart +++ b/frontend2/lib/main.dart @@ -10,6 +10,7 @@ import 'package:frontend2/apis/mess/user_mess_info.dart'; import 'package:frontend2/apis/users/user.dart'; import 'package:frontend2/providers/feedback_provider.dart'; import 'package:frontend2/providers/hostels.dart'; +import 'package:frontend2/providers/room_cleaning_provider.dart'; import 'package:frontend2/screens/main_navigation_screen.dart'; import 'package:frontend2/screens/initial_setup_screen.dart'; import 'package:frontend2/screens/login_screen.dart'; @@ -70,6 +71,7 @@ Future main() async { providers: [ ChangeNotifierProvider(create: (_) => MessInfoProvider()), ChangeNotifierProvider(create: (_) => FeedbackProvider()), + ChangeNotifierProvider(create: (_) => RoomCleaningProvider(),), ], child: MyApp(isLoggedIn: asLoggedIn, updateRequired: updateRequired), ), diff --git a/frontend2/lib/models/room_cleaning_slot.dart b/frontend2/lib/models/room_cleaning_slot.dart new file mode 100644 index 00000000..1fe1ce80 --- /dev/null +++ b/frontend2/lib/models/room_cleaning_slot.dart @@ -0,0 +1,35 @@ +class RoomCleaningSlot { + final String id; + final String weekDay; + final DateTime startTime; + final DateTime endTime; + final int availableSlots; + + RoomCleaningSlot({ + required this.id, + required this.weekDay, + required this.startTime, + required this.endTime, + required this.availableSlots, + }); + + factory RoomCleaningSlot.fromJson( + Map json) { + final dynamic rawId = json['id'] ?? json['_id']; + final String id = rawId?.toString() ?? ""; + final dynamic rawAvailable = json['availableSlots']; + final int availableSlots = rawAvailable is int + ? rawAvailable + : int.tryParse(rawAvailable?.toString() ?? "") ?? 0; + + return RoomCleaningSlot( + id: id, + weekDay: json['weekDay']?.toString() ?? "", + startTime: DateTime.parse( + json['startTime']?.toString() ?? DateTime.now().toIso8601String()), + endTime: DateTime.parse( + json['endTime']?.toString() ?? DateTime.now().toIso8601String()), + availableSlots: availableSlots, + ); + } +} diff --git a/frontend2/lib/providers/room_cleaning_provider.dart b/frontend2/lib/providers/room_cleaning_provider.dart new file mode 100644 index 00000000..bdbd3ff7 --- /dev/null +++ b/frontend2/lib/providers/room_cleaning_provider.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../apis/room_cleaning/room_cleaning_api.dart'; +import '../models/room_cleaning_slot.dart'; + +class RoomCleaningProvider extends ChangeNotifier { + final RoomCleaningApi _api = RoomCleaningApi(); + + List slots = []; + bool isLoading = false; + String? errorMessage; + List myBookings = []; + bool isBookingsLoading = false; + String? bookingsError; + bool localBookingsLoaded = false; + Set localBookedDates = {}; + + Future fetchSlots( + String hostelId, String weekDay) async { + isLoading = true; + errorMessage = null; + notifyListeners(); + + try { + final rawSlots = await _api.fetchSlots(hostelId, weekDay); + slots = rawSlots.map((e) => RoomCleaningSlot.fromJson(e)).toList(); + } catch (e) { + slots = []; + errorMessage = e.toString(); + } finally { + isLoading = false; + notifyListeners(); + } + } + + Future> bookSlot( + String slotId, + String date, + String notes) async { + return await _api.bookSlot( + slotId, date, notes); + } + + Future> cancelBooking( + String bookingId) async { + return await _api.cancelBooking( + bookingId); + } + + Future> getMyBookings() async { + return await _api.getMyBookings(); + } + + String _dateKey(DateTime date) { + return "${date.year.toString().padLeft(4, '0')}-" + "${date.month.toString().padLeft(2, '0')}-" + "${date.day.toString().padLeft(2, '0')}"; + } + + Future loadLocalBookings() async { + if (!RoomCleaningApi.isDev) return; + if (localBookingsLoaded) return; + final prefs = await SharedPreferences.getInstance(); + final saved = prefs.getStringList("room_cleaning_booked_dates") ?? []; + localBookedDates = saved.toSet(); + localBookingsLoaded = true; + notifyListeners(); + } + + bool hasLocalBookingForDate(DateTime date) { + return localBookedDates.contains(_dateKey(date)); + } + + Future fetchMyBookings() async { + isBookingsLoading = true; + bookingsError = null; + notifyListeners(); + + try { + myBookings = await _api.getMyBookings(); + } catch (e) { + myBookings = []; + bookingsError = e.toString(); + } finally { + isBookingsLoading = false; + notifyListeners(); + } + } + + void addLocalBooking({ + required DateTime requestedDate, + RoomCleaningSlot? slot, + }) { + if (!RoomCleaningApi.isDev) { + return; + } + final booking = { + '_id': 'local-booking-${DateTime.now().millisecondsSinceEpoch}', + 'requestedDate': requestedDate.toIso8601String(), + 'status': 'confirmed', + 'slot': slot == null + ? null + : { + '_id': slot.id, + 'weekDay': slot.weekDay, + 'startTime': slot.startTime.toIso8601String(), + 'endTime': slot.endTime.toIso8601String(), + }, + }; + myBookings = [booking, ...myBookings]; + localBookedDates.add(_dateKey(requestedDate)); + SharedPreferences.getInstance().then((prefs) { + prefs.setStringList( + "room_cleaning_booked_dates", + localBookedDates.toList(), + ); + }); + if (slot != null) { + slots = slots.map((s) { + if (s.id != slot.id) return s; + final nextAvailable = (s.availableSlots - 1); + return RoomCleaningSlot( + id: s.id, + weekDay: s.weekDay, + startTime: s.startTime, + endTime: s.endTime, + availableSlots: nextAvailable < 0 ? 0 : nextAvailable, + ); + }).toList(); + } + notifyListeners(); + } +} diff --git a/frontend2/lib/screens/home_screen.dart b/frontend2/lib/screens/home_screen.dart index 6aee7ad1..eb74debc 100644 --- a/frontend2/lib/screens/home_screen.dart +++ b/frontend2/lib/screens/home_screen.dart @@ -15,6 +15,8 @@ import '../utilities/startupitem.dart'; import '../widgets/alerts_card.dart'; import '../widgets/microsoft_required_dialog.dart'; import 'mess_preference.dart'; +import 'package:frontend2/screens/room_cleaning/room_cleaning.dart'; + class HomeScreen extends StatefulWidget { final void Function(int)? onNavigateToTab; @@ -102,6 +104,8 @@ class _HomeScreenState extends State { padding: const EdgeInsets.symmetric(vertical: 18.0), child: Row( children: [ + + /// SCAN QR Expanded( child: InkWell( borderRadius: BorderRadius.circular(18), @@ -111,49 +115,16 @@ class _HomeScreenState extends State { MaterialPageRoute(builder: (context) => const QrScan()), ); }, - child: Container( - height: 90, - decoration: BoxDecoration( - // color: const Color(0xFFF6F6F6), - color: const Color(0xFFFFFFFF), - borderRadius: BorderRadius.circular(18), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - width: 40, - height: 40, - decoration: const BoxDecoration( - color: Color(0xFF3754DB), - shape: BoxShape.circle, - ), - child: Center( - child: SvgPicture.asset( - 'assets/icon/qrscan.svg', - colorFilter: const ColorFilter.mode( - Colors.white, BlendMode.srcIn), - width: 22, - height: 22, - ), - ), - ), - const SizedBox(height: 8), - const Text( - "Scan QR", - style: TextStyle( - color: Colors.black, - fontWeight: FontWeight.w600, - fontSize: 15, - ), - ), - ], - ), + child: _quickActionCard( + iconPath: 'assets/icon/qrscan.svg', + label: "Scan QR", ), ), ), - const SizedBox(width: 16), + + const SizedBox(width: 12), + + /// MESS CHANGE Expanded( child: InkWell( borderRadius: BorderRadius.circular(18), @@ -176,58 +147,87 @@ class _HomeScreenState extends State { Navigator.push( context, - // MaterialPageRoute(builder: (context) => MessChangeScreen()), MaterialPageRoute( - builder: (context) => const MessChangePreferenceScreen()), + builder: (context) => + const MessChangePreferenceScreen(), + ), ); }, - child: Container( - height: 90, - decoration: BoxDecoration( - // color: const Color(0xFFF6F6F6), - color: const Color(0xFFFFFFFF), - borderRadius: BorderRadius.circular(18), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - width: 40, - height: 40, - decoration: const BoxDecoration( - color: Color(0xFF3754DB), - shape: BoxShape.circle, - ), - child: Center( - child: SvgPicture.asset( - 'assets/icon/messicon.svg', - colorFilter: const ColorFilter.mode( - Colors.white, BlendMode.srcIn), - width: 22, - height: 22, - ), - ), - ), - const SizedBox(height: 8), - const Text( - "Mess Change", - style: TextStyle( - color: Colors.black, - fontWeight: FontWeight.w600, - fontSize: 15, - ), - ), - ], - ), + child: _quickActionCard( + iconPath: 'assets/icon/messicon.svg', + label: "Mess Change", ), ), ), + + const SizedBox(width: 12), + + /// ROOM CLEANING + Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const RoomCleaningScreen(), + ), + ); + }, + child: _quickActionCard( + iconPath: 'assets/icon/cleaning.svg', // add icon asset + label: "Room Cleaning", + ), + ), + ), + ], + ), + ); + } + + Widget _quickActionCard({ + required String iconPath, + required String label, + }) { + return Container( + height: 90, + decoration: BoxDecoration( + color: const Color(0xFFFFFFFF), + borderRadius: BorderRadius.circular(18), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 40, + height: 40, + decoration: const BoxDecoration( + color: Color(0xFF3754DB), + shape: BoxShape.circle, + ), + child: const Center( + child: Icon( + Icons.cleaning_services_sharp, + size: 22, + ), + ), + ), + const SizedBox(height: 8), + Text( + label, + style: const TextStyle( + color: Colors.black, + fontWeight: FontWeight.w600, + fontSize: 15, + ), + ), ], ), ); } + + Widget buildMessTodayCard() { return Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/frontend2/lib/screens/room_cleaning/my_cleaning_bookings.dart b/frontend2/lib/screens/room_cleaning/my_cleaning_bookings.dart new file mode 100644 index 00000000..9fbc94c1 --- /dev/null +++ b/frontend2/lib/screens/room_cleaning/my_cleaning_bookings.dart @@ -0,0 +1,230 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart'; +import '../../providers/room_cleaning_provider.dart'; + +class MyCleaningBookingsScreen extends StatefulWidget { + const MyCleaningBookingsScreen({super.key}); + + @override + State createState() => + _MyCleaningBookingsScreenState(); +} + +class _MyCleaningBookingsScreenState + extends State { + + late Future> _futureBookings; + + @override + void initState() { + super.initState(); + _futureBookings = + context.read().getMyBookings(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final provider = context.read(); + provider.loadLocalBookings(); + provider.fetchMyBookings(); + }); + } + + Future _refresh() async { + setState(() { + _futureBookings = + context.read().getMyBookings(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: const Text("My Cleaning Bookings"), + backgroundColor: Colors.white, + foregroundColor: Colors.black, + elevation: 0, + ), + body: FutureBuilder>( + future: _futureBookings, + builder: (context, snapshot) { + + if (snapshot.connectionState == + ConnectionState.waiting) { + return const Center( + child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return const Center( + child: Text("Something went wrong")); + } + + final bookings = snapshot.data ?? []; + + if (bookings.isEmpty) { + return const Center( + child: Text("No bookings yet")); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: bookings.length, + itemBuilder: (context, index) { + final booking = bookings[index]; + final slot = booking['slot']; + + final start = DateFormat.Hm() + .format(DateTime.parse(slot['startTime'])); + final end = DateFormat.Hm() + .format(DateTime.parse(slot['endTime'])); + + final requestedDate = + DateFormat.yMMMd().format( + DateTime.parse( + booking['requestedDate'])); + + final status = booking['status']; + + return Card( + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(12), + ), + child: Padding( + padding: + const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + + /// Time + Text( + "${slot['weekDay'] + .toString() + .toUpperCase()} | $start - $end", + style: const TextStyle( + fontWeight: + FontWeight.w600, + ), + ), + + const SizedBox(height: 6), + + Text("Requested: $requestedDate"), + + if (booking['notes'] != null && + booking['notes'] + .toString() + .isNotEmpty) + Padding( + padding: + const EdgeInsets.only( + top: 6), + child: Text( + "Notes: ${booking['notes']}"), + ), + + const SizedBox(height: 10), + + Row( + mainAxisAlignment: + MainAxisAlignment + .spaceBetween, + children: [ + + /// Status Badge + Container( + padding: + const EdgeInsets + .symmetric( + horizontal: + 10, + vertical: + 4), + decoration: + BoxDecoration( + color: status == + "confirmed" + ? Colors.green + .withOpacity( + 0.1) + : status == + "cancelled" + ? Colors.red + .withOpacity( + 0.1) + : Colors.orange + .withOpacity( + 0.1), + borderRadius: + BorderRadius + .circular( + 6), + ), + child: Text( + status.toUpperCase(), + style: TextStyle( + color: status == + "confirmed" + ? Colors.green + : status == + "cancelled" + ? Colors.red + : Colors + .orange, + fontWeight: + FontWeight + .w600, + ), + ), + ), + + /// Cancel Button + if (status == "confirmed") + TextButton( + onPressed: () async { + + final response = + await context + .read< + RoomCleaningProvider>() + .cancelBooking( + booking[ + '_id']); + + if (!mounted) return; + + ScaffoldMessenger + .of(context) + .showSnackBar( + SnackBar( + content: Text( + response[ + 'message'])), + ); + + _refresh(); + }, + child: const Text( + "Cancel"), + ) + ], + ) + ], + ), + ), + ); + }, + ), + ); + }, + ), + ); + } +} diff --git a/frontend2/lib/screens/room_cleaning/room_cleaning.dart b/frontend2/lib/screens/room_cleaning/room_cleaning.dart new file mode 100644 index 00000000..761db2df --- /dev/null +++ b/frontend2/lib/screens/room_cleaning/room_cleaning.dart @@ -0,0 +1,395 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../providers/room_cleaning_provider.dart'; +import '../../models/room_cleaning_slot.dart'; +import 'package:intl/intl.dart'; +import 'my_cleaning_bookings.dart'; + +class RoomCleaningScreen extends StatefulWidget { + const RoomCleaningScreen({super.key}); + + @override + State createState() => _RoomCleaningScreenState(); +} + +class _RoomCleaningScreenState extends State { + String selectedWeekDay = _getTodayWeekDay(); + String? selectedSlotId; + final TextEditingController notesController = TextEditingController(); + late List availableDates; + DateTime selectedDate = DateTime.now().add(Duration(days: 1)); + bool isBooking = false; + + + static String _getTodayWeekDay() { + return [ + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday' + ][DateTime.now().weekday - 1]; + } + + @override + void initState() { + super.initState(); + + availableDates = [ + DateTime.now().add(const Duration(days: 1)), + DateTime.now().add(const Duration(days: 2)), + ]; + + WidgetsBinding.instance.addPostFrameCallback((_) { + _fetchSlots(); + final provider = context.read(); + provider.loadLocalBookings(); + provider.fetchMyBookings(); + }); + } + + + void _fetchSlots() { + context + .read() + .fetchSlots( + "69071e409bfe9286a32bf865", // hardcoded for barak rn + DateFormat('EEEE').format(selectedDate).toLowerCase(), + ); + } + + bool _isSameDay(DateTime a, DateTime b) { + return a.year == b.year && a.month == b.month && a.day == b.day; + } + + bool _hasBookingForSelectedDate(List bookings) { + for (final booking in bookings) { + final status = booking['status']?.toString(); + if (status == 'cancelled') continue; + final requestedDateRaw = booking['requestedDate']?.toString(); + if (requestedDateRaw == null) continue; + final requestedDate = DateTime.tryParse(requestedDateRaw); + if (requestedDate == null) continue; + if (_isSameDay(requestedDate, selectedDate)) return true; + } + return false; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + + /// Title + const Text( + "Room Cleaning", + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.w600, + color: Color(0xFF2E2F31), + fontFamily: 'OpenSans_regular', + ), + ), + + const SizedBox(height: 24), + + /// Weekday Selector + SizedBox( + height: 40, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: availableDates.length, + itemBuilder: (context, index) { + final date = availableDates[index]; + final isSelected = + selectedDate.day == date.day && + selectedDate.month == date.month && + selectedDate.year == date.year; + + return Padding( + padding: const EdgeInsets.only(right: 8), + child: InkWell( + onTap: () { + setState(() { + selectedDate = date; + selectedSlotId = null; + }); + _fetchSlots(); + }, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 8, horizontal: 14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: isSelected + ? const Color(0xFFEDEDFB) + : const Color(0xFFF5F5F5), + ), + child: Text( + DateFormat('EEE, MMM d').format(date), + style: TextStyle( + color: isSelected + ? const Color(0xFF4C4EDB) + : const Color(0xFF676767), + ), + ), + ), + ), + ); + }, + ), + ), + + + const SizedBox(height: 45), + + const Text( + "Available Slots", + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w500, + color: Color(0xFF676767), + ), + ), + + const SizedBox(height: 16), + + Expanded( + child: Consumer( + builder: (context, provider, child) { + if (provider.isLoading) { + return const Center( + child: CircularProgressIndicator()); + } + + if (provider.errorMessage != null) { + return Center( + child: Text( + provider.errorMessage!, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.red), + ), + ); + } + + final alreadyBookedForDay = + provider.hasLocalBookingForDate(selectedDate) || + _hasBookingForSelectedDate(provider.myBookings); + if (alreadyBookedForDay) { + return const Center( + child: Text( + "You have already booked a slot for this day", + textAlign: TextAlign.center, + ), + ); + } + + if (provider.slots.isEmpty) { + return const Center( + child: Text("No slots available")); + } + + return ListView.builder( + itemCount: provider.slots.length, + itemBuilder: (context, index) { + final slot = provider.slots[index]; + final isSelected = + selectedSlotId == slot.id; + final isFull = slot.availableSlots == 0; + + final start = + DateFormat.Hm().format(slot.startTime); + final end = + DateFormat.Hm().format(slot.endTime); + + return Padding( + padding: + const EdgeInsets.only(bottom: 12), + child: InkWell( + onTap: isFull || alreadyBookedForDay + ? null + : () { + setState(() { + selectedSlotId = + slot.id; + }); + }, + child: Container( + padding: + const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: + BorderRadius.circular(12), + color: isSelected + ? const Color(0xFFEDEDFB) + : const Color(0xFFF5F5F5), + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment + .spaceBetween, + children: [ + Text( + "$start - $end", + style: + const TextStyle( + fontWeight: + FontWeight.w600, + ), + ), + Text( + isFull + ? "Full" + : "${slot.availableSlots} left", + style: TextStyle( + color: isFull + ? Colors.red + : Colors.green, + ), + ), + ], + ), + ), + ), + ); + }, + ); + }, + ), + ), + + const SizedBox(height: 12), + + /// Notes + TextField( + controller: notesController, + maxLines: 2, + decoration: InputDecoration( + hintText: "Add notes (optional)", + filled: true, + fillColor: + const Color(0xFFF5F5F5), + border: OutlineInputBorder( + borderRadius: + BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + + const SizedBox(height: 20), + + /// Book Button + SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: + const Color(0xFF4C4EDB), + padding: + const EdgeInsets.symmetric( + vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(12), + ), + ), + onPressed: selectedSlotId == null || isBooking + ? null + : () async { + final alreadyBookedForDay = + _hasBookingForSelectedDate( + context.read().myBookings, + ); + if (alreadyBookedForDay) return; + + setState(() { + isBooking = true; + }); + + try { + RoomCleaningSlot? selectedSlot; + final allSlots = + context.read().slots; + for (final slot in allSlots) { + if (slot.id == selectedSlotId) { + selectedSlot = slot; + break; + } + } + + final response = await context + .read() + .bookSlot( + selectedSlotId!, + DateFormat('yyyy-MM-dd').format(selectedDate), + notesController.text, + ); + + if (!mounted) return; + + if (response['status'] == 'confirmed') { + context.read().addLocalBooking( + requestedDate: selectedDate, + slot: selectedSlot, + ); + setState(() { + selectedSlotId = null; + }); + notesController.clear(); + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(response['message'])), + ); + + // Keep local optimistic state; backend dev mocks won't + // reflect updated availability immediately. + } finally { + if (mounted) { + setState(() { + isBooking = false; + }); + } + } + }, + child: const Text( + "Book Slot", + style: TextStyle( + fontWeight: + FontWeight.w600, + color: Colors.white, + ), + ), + ), + ), + + const SizedBox(height: 12), + + Center( + child: TextButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const MyCleaningBookingsScreen()) + ); + }, + child: const Text( + "View My Bookings"), + ), + ) + ], + ), + ), + ), + ); + } +} diff --git a/frontend2/macos/Flutter/GeneratedPluginRegistrant.swift b/frontend2/macos/Flutter/GeneratedPluginRegistrant.swift index 71cc0fe6..a595e77c 100644 --- a/frontend2/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/frontend2/macos/Flutter/GeneratedPluginRegistrant.swift @@ -19,7 +19,6 @@ import flutter_web_auth_2 import google_sign_in_ios import mobile_scanner import package_info_plus -import path_provider_foundation import shared_preferences_foundation import sign_in_with_apple import url_launcher_macos @@ -40,7 +39,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/frontend2/pubspec.lock b/frontend2/pubspec.lock index 5f3ed86c..30428a46 100644 --- a/frontend2/pubspec.lock +++ b/frontend2/pubspec.lock @@ -81,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" collection: dependency: transitive description: @@ -109,10 +117,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" url: "https://pub.dev" source: hosted - version: "0.3.5+1" + version: "0.3.5+2" crypto: dependency: transitive description: @@ -133,10 +141,10 @@ packages: dependency: transitive description: name: dbus - sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" desktop_webview_window: dependency: transitive description: @@ -165,10 +173,10 @@ packages: dependency: "direct main" description: name: dio - sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + sha256: b9d46faecab38fc8cc286f80bc4d61a3bb5d4ac49e51ed877b4d6706efe57b25 url: "https://pub.dev" source: hosted - version: "5.9.0" + version: "5.9.1" dio_web_adapter: dependency: transitive description: @@ -213,10 +221,10 @@ packages: dependency: transitive description: name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" file: dependency: transitive description: @@ -504,6 +512,14 @@ packages: description: flutter source: sdk version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" google_identity_services_web: dependency: transitive description: @@ -568,6 +584,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" http: dependency: "direct main" description: @@ -588,10 +612,10 @@ packages: dependency: transitive description: name: image - sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c" url: "https://pub.dev" source: hosted - version: "4.5.4" + version: "4.7.2" image_picker: dependency: "direct main" description: @@ -604,10 +628,10 @@ packages: dependency: transitive description: name: image_picker_android - sha256: "5e9bf126c37c117cf8094215373c6d561117a3cfb50ebc5add1a61dc6e224677" + sha256: "518a16108529fc18657a3e6dde4a043dc465d16596d20ab2abd49a4cac2e703d" url: "https://pub.dev" source: hosted - version: "0.8.13+10" + version: "0.8.13+13" image_picker_for_web: dependency: transitive description: @@ -620,10 +644,10 @@ packages: dependency: transitive description: name: image_picker_ios - sha256: "997d100ce1dda5b1ba4085194c5e36c9f8a1fb7987f6a36ab677a344cd2dc986" + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 url: "https://pub.dev" source: hosted - version: "0.8.13+2" + version: "0.8.13+6" image_picker_linux: dependency: transitive description: @@ -668,10 +692,10 @@ packages: dependency: transitive description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df" url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "4.10.0" leak_tracker: dependency: transitive description: @@ -704,6 +728,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" lottie: dependency: "direct main" description: @@ -760,6 +792,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.1.4" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + url: "https://pub.dev" + source: hosted + version: "0.17.4" nested: dependency: transitive description: @@ -776,6 +816,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" package_info_plus: dependency: "direct main" description: @@ -828,10 +876,10 @@ packages: dependency: transitive description: name: path_provider_foundation - sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.5.1" + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -908,10 +956,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "7.0.2" platform: dependency: transitive description: @@ -944,6 +992,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" qr: dependency: transitive description: @@ -964,18 +1020,18 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" url: "https://pub.dev" source: hosted - version: "2.5.3" + version: "2.5.4" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "46a46fd64659eff15f4638bbe19de43f9483f0e0bf024a9fb6b3582064bacc7b" + sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f url: "https://pub.dev" source: hosted - version: "2.4.17" + version: "2.4.20" shared_preferences_foundation: dependency: transitive description: @@ -1049,10 +1105,10 @@ packages: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.1" + version: "1.10.2" stack_trace: dependency: transitive description: @@ -1129,10 +1185,10 @@ packages: dependency: transitive description: name: url_launcher_ios - sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" url: "https://pub.dev" source: hosted - version: "6.3.6" + version: "6.4.1" url_launcher_linux: dependency: transitive description: @@ -1161,10 +1217,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" url_launcher_windows: dependency: transitive description: @@ -1193,10 +1249,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc + sha256: "201e876b5d52753626af64b6359cd13ac6011b80728731428fd34bc840f71c9b" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.1.20" vector_math: dependency: transitive description: @@ -1209,10 +1265,10 @@ packages: dependency: "direct main" description: name: vibration - sha256: "1fd51cb0f91c6d512734ca0e282dd87fbc7f389b6da5f03c77709ba2cf8fa901" + sha256: "3cbdf4c93b469ec27b212c8dd0c720f85acc1186a68d08b56087f4e32b4c8e20" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.6" vibration_platform_interface: dependency: transitive description: @@ -1286,5 +1342,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.0 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/server/v1/index.js b/server/v1/index.js index 850c07d9..2313f2e2 100644 --- a/server/v1/index.js +++ b/server/v1/index.js @@ -14,6 +14,7 @@ const notificationRoute = require("./modules/notification/notificationRoute.js") const messRoute = require("./modules/mess/messRoute.js"); const logsRoute = require("./modules/mess/ScanLogsRoute.js"); const bugReportRoute = require("./modules/bug_report/bugReportRoute.js"); +const roomCleaningRoute = require("./modules/room_cleaning/roomCleaningRoute.js"); const cors = require("cors"); const bodyParser = require("body-parser"); const { @@ -222,6 +223,9 @@ app.use("/api/logs", logsRoute); // Bug report route app.use("/api/bug-report", bugReportRoute); +// Room cleaning route +app.use("/api/room-cleaning", roomCleaningRoute); + // Debug route: accept delegated tokens and save to disk for server use // WARNING: Protect this route in production (e.g., require admin auth, restrict IPs) app.post("/api/_debug/graph/delegated-token", async (req, res) => { diff --git a/server/v1/modules/room_cleaning/roomCleaningBookingController.js b/server/v1/modules/room_cleaning/roomCleaningBookingController.js new file mode 100644 index 00000000..e69de29b diff --git a/server/v1/modules/room_cleaning/roomCleaningModel.js b/server/v1/modules/room_cleaning/roomCleaningModel.js new file mode 100644 index 00000000..e69de29b diff --git a/server/v1/modules/room_cleaning/roomCleaningRoute.js b/server/v1/modules/room_cleaning/roomCleaningRoute.js new file mode 100644 index 00000000..e69de29b diff --git a/server/v1/modules/room_cleaning/roomCleaningScheduleService.js b/server/v1/modules/room_cleaning/roomCleaningScheduleService.js new file mode 100644 index 00000000..e69de29b diff --git a/server/v1/modules/room_cleaning/roomCleaningSlotController.js b/server/v1/modules/room_cleaning/roomCleaningSlotController.js new file mode 100644 index 00000000..e69de29b