diff --git a/lib/constants.dart b/lib/constants.dart index fe2e5ec..54c5873 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -105,6 +105,16 @@ enum ColorType { sliderBackground, sliderButton, + roomSearchBg, + roomSearchText, + + // temporary room search label cards, will maybe change + roomSearchBathroom, + roomSearchInfoDesk, + roomSearchDining, // do we have dining? + roomSearchPrinting, // do we have printing data? + roomSearchStairs, + // info card colors (in route selector, favorites sheet, etc.) infoCardColor, infoCardHighlighted, @@ -149,6 +159,15 @@ const Map lightColors = { ColorType.sliderButton: Colors.white, ColorType.sliderBackground: Color.fromARGB(255, 200, 228, 255), + ColorType.roomSearchBg: Color.fromARGB(255, 223, 237, 252), + ColorType.roomSearchText: Color.fromARGB(255, 107, 137, 163), + + ColorType.roomSearchBathroom: Color.fromARGB(255, 151, 132, 193), + ColorType.roomSearchInfoDesk: Color.fromARGB(255, 121, 176, 235), + ColorType.roomSearchDining: Color.fromARGB(255, 235, 162, 121), + ColorType.roomSearchPrinting: Color.fromARGB(255, 238, 141, 210), + ColorType.roomSearchStairs: Color.fromARGB(255, 134, 195, 166), + ColorType.infoCardColor: Color.fromARGB(255, 255, 255, 255), ColorType.infoCardHighlighted: Color.fromARGB(255, 200, 228, 255), @@ -193,6 +212,15 @@ const Map darkColors = { ColorType.sliderButton: Color.fromARGB(255, 32, 33, 34), ColorType.sliderBackground: Color.fromARGB(255, 33, 71, 105), + + // dark mode colors may vary in the future! + ColorType.roomSearchBg: Color.fromARGB(255, 223, 237, 252), + ColorType.roomSearchText: Color.fromARGB(255, 107, 137, 163), + ColorType.roomSearchBathroom: Color.fromARGB(255, 151, 132, 193), + ColorType.roomSearchInfoDesk: Color.fromARGB(255, 121, 176, 235), + ColorType.roomSearchDining: Color.fromARGB(255, 235, 162, 121), + ColorType.roomSearchPrinting: Color.fromARGB(255, 238, 141, 210), + ColorType.roomSearchStairs: Color.fromARGB(255, 134, 195, 166), ColorType.infoCardColor: Color.fromARGB(255, 47, 54, 60), ColorType.infoCardHighlighted: Color.fromARGB(255, 33, 71, 105), diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 76564d7..3611cc9 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -27,6 +27,7 @@ import 'package:bluebus/widgets/journey_results_widget.dart'; import 'package:bluebus/widgets/loading_screen.dart'; import 'package:bluebus/widgets/navigation_overlay_widget.dart'; import 'package:bluebus/widgets/reminder_widgets.dart'; +import 'package:bluebus/widgets/search_rooms.dart'; import 'package:bluebus/widgets/search_sheet_main.dart'; import 'package:bluebus/widgets/stop_sheet.dart'; import 'package:flutter/foundation.dart'; @@ -1093,6 +1094,19 @@ class _MaizeBusCoreState extends State { ); } + void _showSearchRoomsSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (BuildContext context) { + return SearchRoomsSheet( + floors: floorplansLayer.floors, + ); + }, + ); + } + void _showBuildingSheet(Location place) { // Show red pin at the location _showSearchLocationMarker(place.latlng!.latitude, place.latlng!.longitude); @@ -2429,6 +2443,7 @@ class _MaizeBusCoreState extends State { _floorplanOverlayEnabled = true; }); }, + onRoomSearch: _showSearchRoomsSheet, ), ), ), @@ -2457,6 +2472,7 @@ class _MaizeBusCoreState extends State { _floorplanOverlayEnabled = false; }); }, + onRoomSearch: _showSearchRoomsSheet, ), ), ), diff --git a/lib/widgets/floorplan_overlay_widget.dart b/lib/widgets/floorplan_overlay_widget.dart index 9781c64..cb5261e 100644 --- a/lib/widgets/floorplan_overlay_widget.dart +++ b/lib/widgets/floorplan_overlay_widget.dart @@ -25,8 +25,9 @@ const FLOOR_SELECTED_HIGHLIGHT_MARGIN = 5.0; /// full [FloorplanOverlay] floor picker. class FloorplanEntryBar extends StatelessWidget { final VoidCallback onOpenFloorplan; + final VoidCallback onRoomSearch; - const FloorplanEntryBar({super.key, required this.onOpenFloorplan}); + const FloorplanEntryBar({super.key, required this.onOpenFloorplan, required this.onRoomSearch}); @override Widget build(BuildContext context) { @@ -86,7 +87,7 @@ class FloorplanEntryBar extends StatelessWidget { borderRadius: BorderRadius.circular(25), ), child: ElevatedButton.icon( - onPressed: onOpenFloorplan, + onPressed: onRoomSearch, style: ElevatedButton.styleFrom( alignment: Alignment.centerLeft, backgroundColor: @@ -451,7 +452,8 @@ class FloorOutlineClipper extends CustomClipper { class FloorplanOverlay extends StatefulWidget { // const FloorplanOverlauy - Function? onClosed; + final VoidCallback onClosed; + final VoidCallback onRoomSearch; /// The map layer this overlay drives. Picking a floor here is what swaps the /// geometry drawn on the map underneath. @@ -459,7 +461,8 @@ class FloorplanOverlay extends StatefulWidget { FloorplanOverlay({ required this.onClosed, - required this.floorplansLayer + required this.floorplansLayer, + required this.onRoomSearch }); @override @@ -847,8 +850,6 @@ class _FloorplanOverlayState extends State with TickerProvider // mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.end, children: [ - - // Nothing to pick between until the floorplan has loaded. if (floors.isNotEmpty) FloorSelector( @@ -869,47 +870,35 @@ class _FloorplanOverlayState extends State with TickerProvider // ), SizedBox(width: 8,), Expanded( - child: Container( - decoration: BoxDecoration( - color: Colors.white, // TODO: Make dynamic for light/dark mode - borderRadius: BorderRadius.all(Radius.circular(30)) + child: FilledButton.icon( + onPressed: widget.onRoomSearch, + style: FilledButton.styleFrom( + alignment: Alignment.centerLeft, + backgroundColor: + Colors.white, // TODO: Make dynamic for light/dark mode + padding: const EdgeInsets.symmetric( + horizontal: 13, + vertical: 8, + ), ), - child: Padding( - padding: EdgeInsetsGeometry.only(left: 20, right: 20, top: 10, bottom: 10), - child: Row( - children: [ - Icon( - Icons.search, - color: Colors.black, - size: 30, - ), - SizedBox(width: 5), - Text( - "Room #", - style: TextStyle( - color: Colors.black, - fontSize: 18 - ), - - ), - ], - ) - - - ) - ) - + icon: const Icon( + Icons.search_sharp, + color: Colors.black, + size: 28, + ), + label: const Text( + "Room #", + style: TextStyle(color: Colors.black, fontSize: 18), + ), + ), ) - ], ) ) ] ), ) - ], ); } - } \ No newline at end of file diff --git a/lib/widgets/search_rooms.dart b/lib/widgets/search_rooms.dart new file mode 100644 index 0000000..91c37a8 --- /dev/null +++ b/lib/widgets/search_rooms.dart @@ -0,0 +1,338 @@ +import 'package:bluebus/models/floorplan.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import '../constants.dart'; +// import 'package:http/http.dart' as http; + +class Card { + final String name; + final ColorType color; + const Card({required this.name, required this.color}); +} + +class RoomSearchBar extends HookWidget { + final void Function(FloorplanFloor, FloorplanPoi) onLocationSelected; + final TextEditingController controller; + final FocusNode focusNode; + final InputDecoration decoration; + final List floors; + + // selectable cards + static const List cards = [ + Card(name: "Bathroom", color: ColorType.roomSearchBathroom), + Card(name: "Info desk", color: ColorType.roomSearchInfoDesk), + Card(name: "Dining", color: ColorType.roomSearchDining), + Card(name: "Printing", color: ColorType.roomSearchPrinting), + Card(name: "Stairs", color: ColorType.roomSearchStairs), + ]; + + const RoomSearchBar({ + super.key, + required this.onLocationSelected, + required this.controller, + required this.focusNode, + required this.decoration, + required this.floors, + }); + + @override + Widget build(BuildContext context) { + final showSuggestions = useState(false); + useEffect(() { + void listener() { + if (!focusNode.hasFocus) { + showSuggestions.value = false; + } + } + + focusNode.addListener(listener); + return () => focusNode.removeListener(listener); + }, [focusNode]); + + final searchQuery = useState(''); + + // simple search for now + // TODO: needs to be sorted by order of closest distance still + // and also account for different buildings, which it does not do + List<({FloorplanFloor floor, FloorplanPoi poi})> search(String query) { + final normalizedQuery = query.trim().toLowerCase(); + if (normalizedQuery.isEmpty) return []; + + final matches = <({FloorplanFloor floor, FloorplanPoi poi})>[]; + for (final floor in floors) { + for (final poi in floor.pois) { + if (poi.type == FloorplanTypes.waypoint1 || + poi.type == FloorplanTypes.waypoint2) { + continue; + } + if ((poi.name ?? '').toLowerCase().contains(normalizedQuery)) { + matches.add((floor: floor, poi: poi)); + } + } + } + return matches; + } + + // Memoize search results. + final results = useMemoized(() async { + return search(searchQuery.value); + }, [searchQuery.value, floors]); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsetsGeometry.symmetric(horizontal: 16), + child: SizedBox( + height: 50, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(56), + ), + child: TextField( + textAlignVertical: TextAlignVertical.center, + textInputAction: TextInputAction.go, + style: TextStyle( + color: getColor(context, ColorType.opposite).withAlpha(204), + fontSize: 22, + ), + autofocus: true, + controller: controller, + focusNode: focusNode, + decoration: decoration, + onChanged: (val) { + searchQuery.value = val; + showSuggestions.value = true; + }, + onSubmitted: (val) { + final matches = search(val); + if (matches.isNotEmpty) { + final selected = matches.first; + controller.text = selected.poi.name ?? selected.poi.type; + onLocationSelected(selected.floor, selected.poi); + showSuggestions.value = false; + } + }, + ), + ), + ), + ), + + const SizedBox(height: 20), + + // Cards + SizedBox( + height: 31, + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 16), + scrollDirection: Axis.horizontal, + itemCount: cards.length, + itemBuilder: (BuildContext context, int index) { + return SizedBox( + height: 31, + child: FilledButton( + onPressed: () {}, + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + 100.0, + ), // Adjust the pixels for rounding + ), + backgroundColor: getColor(context, cards[index].color), + padding: EdgeInsets.symmetric(horizontal: 10), + ), + child: Text( + cards[index].name, + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + ), + ); + }, + // This builds the gap between each item + separatorBuilder: (context, index) { + return const SizedBox( + width: 5, + ); // Adjust height for vertical lists, width for horizontal + }, + ), + ), + + const SizedBox(height: 20), + + Padding( + padding: const EdgeInsetsGeometry.symmetric(horizontal: 16), + child: FutureBuilder>( + future: results, + builder: (context, snapshot) { + if (!showSuggestions.value || searchQuery.value.isEmpty) { + return Text( + "Recent Searches", + style: TextStyle( + color: getColor(context, ColorType.roomSearchText), + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ); + } + + // Loading circle + if (snapshot.connectionState == ConnectionState.waiting) { + return Padding( + padding: const EdgeInsets.only(top: 50), + child: Center( + child: SizedBox( + width: 30, + height: 30, + child: CircularProgressIndicator( + color: getColor(context, ColorType.opposite), + strokeWidth: 4, + ), + ), + ), + ); + } + + if (!snapshot.hasData) { + return const SizedBox.shrink(); + } + + // Has data, return matching POIs. + final matches = snapshot.data!; + return ListView.separated( + itemCount: matches.length, + physics: NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemBuilder: (context, index) { + final result = matches[index]; + final poi = result.poi; + return ListTile( + contentPadding: EdgeInsets.only(left: 2, right: 2), + title: Text( + poi.name ?? poi.type, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text( + "Duderstadt Building ${result.floor.name}" + ), + leading: Icon( + Icons.business_rounded, + size: 40, + color: getColor(context, ColorType.opposite) + ), + onTap: () { + controller.text = poi.name ?? poi.type; + onLocationSelected(result.floor, poi); + showSuggestions.value = false; + }, + ); + }, + separatorBuilder: (BuildContext context, int index) { + return Divider(height: 15); + }, + ); + }, + ), + ), + ], + ); + } +} + +// Selecting routes +class SearchRoomsSheet extends StatefulWidget { + final List floors; + + const SearchRoomsSheet({super.key, required this.floors}); + + @override + State createState() => _SearchRoomsSheetState(); +} + +class _SearchRoomsSheetState extends State { + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(); + + @override + void dispose() { + _searchController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + height: MediaQuery.of(context).size.height * 0.9, + decoration: BoxDecoration( + color: getColor(context, ColorType.background), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(30), + topRight: Radius.circular(30), + ), + boxShadow: [SheetBoxShadow], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 16, top: 20, bottom: 4), + child: Row( + children: [ + const Text( + 'Search', + style: TextStyle( + fontFamily: 'Urbanist', + fontWeight: FontWeight.w700, + fontSize: 30, + ), + ), + + SizedBox(width: 10), + ], + ), + ), + + const SizedBox(height: 10), + + RoomSearchBar( + onLocationSelected: (floor, poi) { + Navigator.pop(context, (floor: floor, poi: poi)); + }, + controller: _searchController, + focusNode: _searchFocusNode, + decoration: InputDecoration( + fillColor: getColor(context, ColorType.roomSearchBg), + filled: true, + hintText: 'Room #', + hintStyle: TextStyle( + color: getColor(context, ColorType.roomSearchText), + fontSize: 22, + ), + isCollapsed: true, + prefixIcon: Padding( + padding: EdgeInsetsGeometry.only(left: 15), + child: Icon( + Icons.search, + size: 35, + color: getColor(context, ColorType.roomSearchText), + ), + ), + + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(56.0)), + borderSide: BorderSide(color: Colors.transparent, width: 0), + ), + + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(56.0)), + borderSide: BorderSide(color: Colors.transparent, width: 0), + ), + ), + floors: widget.floors, + ), + ], + ), + ); + } +}