diff --git a/lib/main.dart b/lib/main.dart index 068d3fff..2f665b4d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -101,7 +101,7 @@ void main() async { Adaptive.ignoreHeight = true; - setPathUrlStrategy(); + // setPathUrlStrategy(); // initializeEchidnaApi(baseUrl: kEchidnaHost, clientKey: kEchidnaClientKey, clientId: kEchidnaClientID); diff --git a/lib/src/app/presentation/widgets/title_bar.dart b/lib/src/app/presentation/widgets/title_bar.dart index bdb03d3a..96efe431 100644 --- a/lib/src/app/presentation/widgets/title_bar.dart +++ b/lib/src/app/presentation/widgets/title_bar.dart @@ -210,11 +210,12 @@ class TitleBarState extends State with WindowListener, RouteAware, Ada crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( + spacing: Spacing.mediumSpacing, children: [ ConditionalWrapper( condition: _parentRoute != null, wrapper: (_, child) => TextButton( - onPressed: () => Modular.to.navigate(_parentRoute!), + onPressed: () => Modular.to.pushNamed(_parentRoute!), child: Row( children: [ const Icon(FontAwesome5Solid.angle_left), @@ -314,7 +315,7 @@ class TitleBarState extends State with WindowListener, RouteAware, Ada if (user.vintage != null) user.vintage!.humanReadable.text.color(context.theme.colorScheme.primary).color(context.theme.colorScheme.primary) else if (user.capabilities.isNotEmpty) - Text(user.capabilities.highest.translate(context)).color(context.theme.colorScheme.primary), + Text(user.capabilities.map((c) => c.translate(context)).join(', ')).color(context.theme.colorScheme.primary), ], ), ].show(), @@ -367,7 +368,7 @@ class TitleBarState extends State with WindowListener, RouteAware, Ada ConditionalWrapper( condition: _parentRoute != null, wrapper: (_, child) => TextButton( - onPressed: () => Modular.to.navigate(_parentRoute!), + onPressed: () => Modular.to.pushNamed(_parentRoute!), child: Row( children: [ const Icon(FontAwesome5Solid.angle_left), diff --git a/lib/src/app/utils/no_mobile_utils.dart b/lib/src/app/utils/no_mobile_utils.dart index bb619bcd..2ad83f93 100644 --- a/lib/src/app/utils/no_mobile_utils.dart +++ b/lib/src/app/utils/no_mobile_utils.dart @@ -19,7 +19,7 @@ mixin NoMobile on Adaptive { image: Assets.mobile, ).expanded(), ElevatedButton( - onPressed: () => Modular.to.navigate('/dashboard/'), + onPressed: () => Modular.to.pushNamed('/dashboard/'), child: Text(context.t.app_noMobile_goBack), ), ], diff --git a/lib/src/course_overview/presentation/screens/course_overview_screen.dart b/lib/src/course_overview/presentation/screens/course_overview_screen.dart index 43c67fff..352eb846 100644 --- a/lib/src/course_overview/presentation/screens/course_overview_screen.dart +++ b/lib/src/course_overview/presentation/screens/course_overview_screen.dart @@ -162,7 +162,7 @@ class _CourseOverviewScreenState extends State with Adapti ).toList(), ), ), - ), + ).greedy(), ); } } diff --git a/lib/src/course_overview/presentation/screens/courses_overview_screen.dart b/lib/src/course_overview/presentation/screens/courses_overview_screen.dart index 63c025cf..6dc44452 100644 --- a/lib/src/course_overview/presentation/screens/courses_overview_screen.dart +++ b/lib/src/course_overview/presentation/screens/courses_overview_screen.dart @@ -40,6 +40,7 @@ class _CoursesOverviewScreenState extends State with Adap return Padding( padding: PaddingAll(), child: SingleChildScrollView( + clipBehavior: Clip.none, child: Wrap( runSpacing: Spacing.mediumSpacing, spacing: Spacing.mediumSpacing, @@ -51,7 +52,7 @@ class _CoursesOverviewScreenState extends State with Adap ), ].show(), ), - ), + ).greedy(), ); } } diff --git a/lib/src/moodle/presentation/widgets/user_widget.dart b/lib/src/moodle/presentation/widgets/user_widget.dart index d6ef2991..7a2be02f 100644 --- a/lib/src/moodle/presentation/widgets/user_widget.dart +++ b/lib/src/moodle/presentation/widgets/user_widget.dart @@ -1,10 +1,11 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:eduplanner/eduplanner.dart'; import 'package:flutter/material.dart'; /// Displays the user's profile image and name. class UserWidget extends StatelessWidget { /// Displays the user's profile image and name. - const UserWidget({super.key, required this.user, this.size = 20, this.style, this.expand = false}); + const UserWidget({super.key, required this.user, this.size = 20, this.style, this.flexible = false}); /// The user to display. final User user; @@ -16,27 +17,23 @@ class UserWidget extends StatelessWidget { final TextStyle? style; /// If true the widget will expand to fill the available space. - final bool expand; + final bool flexible; @override Widget build(BuildContext context) { return Row( - mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min, + mainAxisSize: MainAxisSize.min, children: [ UserProfileImage( userId: user.id, size: size, ), Spacing.smallHorizontal(), - ConditionalWrapper( - condition: expand, - wrapper: (context, child) => Expanded(child: child), - child: Text( - user.fullname, - style: style, - overflow: TextOverflow.ellipsis, - ), - ), + Text( + user.fullname, + style: style, + overflow: TextOverflow.ellipsis, + ).flexible(), ], ); } diff --git a/lib/src/slots/domain/models/slot.dart b/lib/src/slots/domain/models/slot.dart index 7756b3ff..82aff301 100644 --- a/lib/src/slots/domain/models/slot.dart +++ b/lib/src/slots/domain/models/slot.dart @@ -131,7 +131,7 @@ enum Weekday { for (var i = 0; i < 7; i++) { final date = now.add(Duration(days: i)); if (date.weekday == index + 1) { - return date; + return date.copyWith(hour: 0, minute: 0, second: 0, millisecond: 0, microsecond: 0); } } throw StateError('Could not find next date for $this'); diff --git a/lib/src/slots/presentation/repositories/slot_master_courses_repository.dart b/lib/src/slots/presentation/repositories/slot_master_courses_repository.dart index c7bc367a..4aa09557 100644 --- a/lib/src/slots/presentation/repositories/slot_master_courses_repository.dart +++ b/lib/src/slots/presentation/repositories/slot_master_courses_repository.dart @@ -59,6 +59,22 @@ class SlotMasterCoursesRepository extends Repository element.id == id); + } catch (e) { + return null; + } + } + @override void dispose() { super.dispose(); diff --git a/lib/src/slots/presentation/repositories/slot_master_slots_repository.dart b/lib/src/slots/presentation/repositories/slot_master_slots_repository.dart index 602497bc..5407bd63 100644 --- a/lib/src/slots/presentation/repositories/slot_master_slots_repository.dart +++ b/lib/src/slots/presentation/repositories/slot_master_slots_repository.dart @@ -12,10 +12,14 @@ import 'package:mcquenji_core/mcquenji_core.dart'; class SlotMasterSlotsRepository extends Repository>> with Tracable { final AuthRepository _auth; final SlotsDatasource _datasource; + final SlotMasterCoursesRepository _courses; + final UsersRepository _users; /// Provides data for the slot master screen. - SlotMasterSlotsRepository(this._auth, this._datasource) : super(AsyncValue.loading()) { + SlotMasterSlotsRepository(this._auth, this._datasource, this._courses, this._users) : super(AsyncValue.loading()) { watchAsync(_auth); + watchAsync(_courses); + watchAsync(_users); _datasource.parent = this; } @@ -360,6 +364,42 @@ class SlotMasterSlotsRepository extends Repository>> with }; } + /// Queries the slots for the given [query]. + /// + /// A query matches a slot if the room, any course name, any vintage or any supervisor's name contains the query (case-insensitive). + List query(String query, {Iterable? slots}) { + final _slots = slots ?? state.data; + + if (_slots == null) { + log('Cannot query slots: No data'); + return []; + } + + if (query.isEmpty) { + return _slots.toList(); + } + + return _slots.where((slot) { + if (slot.room.containsIgnoreCase(query)) { + return true; + } + + if (slot.mappings.any((m) => m.vintage.humanReadable.containsIgnoreCase(query))) { + return true; + } + + if (slot.mappings.any((m) => _courses.getById(m.courseId)?.name.containsIgnoreCase(query) ?? false)) { + return true; + } + + if (_users.filter(ids: slot.supervisors).any((u) => u.fullname.containsIgnoreCase(query))) { + return true; + } + + return false; + }).toList(); + } + @override void dispose() { _datasource.dispose(); @@ -368,6 +408,7 @@ class SlotMasterSlotsRepository extends Repository>> with } /// Adds a query method to the [Slot] iterable. +@Deprecated('Use SlotsRepository.query instead') extension QueryX on Iterable { /// Queries the slots for the given [query]. List query(String query) { diff --git a/lib/src/slots/presentation/screens/slot_details_screen.dart b/lib/src/slots/presentation/screens/slot_details_screen.dart index c3f6db27..b40b6e09 100644 --- a/lib/src/slots/presentation/screens/slot_details_screen.dart +++ b/lib/src/slots/presentation/screens/slot_details_screen.dart @@ -23,7 +23,9 @@ class _SlotDetailsScreenState extends State with AdaptiveStat void didChangeDependencies() { super.didChangeDependencies(); - Data.of(context).setParentRoute('/slots/overview/'); + Data.of(context) + ..setParentRoute('/slots/overview/') + ..setTrailingWidget(const SlotsViewSwitcher()); } @override @@ -44,7 +46,7 @@ class _SlotDetailsScreenState extends State with AdaptiveStat final courseVintage = slot.mappings .map((m) { - final course = courseRepository.filter(id: m.courseId).firstOrNull; + final course = courseRepository.getById(m.courseId); final vintage = m.vintage; return (course, vintage); @@ -103,7 +105,7 @@ class _SlotDetailsScreenState extends State with AdaptiveStat children: [ for (final supervisor in supervisors) UserWidget(user: supervisor), ], - ).stretch(), + ).expanded(), ], ), ), @@ -121,20 +123,9 @@ class _SlotDetailsScreenState extends State with AdaptiveStat spacing: Spacing.mediumSpacing, runSpacing: Spacing.mediumSpacing, children: [ - for (final (course, vintage) in courseVintage) - Row( - mainAxisSize: MainAxisSize.min, - children: [ - CourseTag(course: course!), - Spacing.xsHorizontal(), - Text(course.name), - Spacing.smallHorizontal(), - Text(vintage.humanReadable), - Spacing.mediumHorizontal(), - ], - ), + for (final (course, vintage) in courseVintage) MappingWidget(course: course!, vintage: vintage), ], - ).stretch(), + ).expanded(), ], ), ), @@ -180,7 +171,7 @@ class _SlotDetailsScreenState extends State with AdaptiveStat ), ], ), - ), + ).stretch(), ), ).expanded(flex: 6), ], diff --git a/lib/src/slots/presentation/screens/slot_master_screen.dart b/lib/src/slots/presentation/screens/slot_master_screen.dart index 116f7af4..52bb47bf 100644 --- a/lib/src/slots/presentation/screens/slot_master_screen.dart +++ b/lib/src/slots/presentation/screens/slot_master_screen.dart @@ -41,7 +41,9 @@ class _SlotMasterScreenState extends State with AdaptiveState, void didChangeDependencies() { super.didChangeDependencies(); - Data.of(context).setSearchController(searchController); + Data.of(context) + ..setSearchController(searchController) + ..setTrailingWidget(const SlotsViewSwitcher()); } void createSlot(Weekday weekday, SlotTimeUnit startUnit) { @@ -80,7 +82,7 @@ class _SlotMasterScreenState extends State with AdaptiveState, child: TabBarView( controller: _tabController, children: [ - for (final weekday in Weekday.values) slotTimeTable(groups[weekday] ?? >{}, weekday), + for (final weekday in Weekday.values) slotTimeTable(groups[weekday] ?? >{}, weekday, slots), ], ), ), @@ -88,7 +90,7 @@ class _SlotMasterScreenState extends State with AdaptiveState, ); } - Widget slotTimeTable(Map> activeGroup, Weekday weekday) { + Widget slotTimeTable(Map> activeGroup, Weekday weekday, SlotMasterSlotsRepository repo) { return SingleChildScrollView( child: Column( spacing: Spacing.largeSpacing, @@ -117,7 +119,7 @@ class _SlotMasterScreenState extends State with AdaptiveState, runSpacing: Spacing.mediumSpacing, children: [ // TODO(mastermarcohd): implement more intelligent sorting to account for building and floor. - for (final slot in (activeGroup[timeUnit] ?? []).query(searchController.text).sortedBy((s) => s.room)) + for (final slot in repo.query(searchController.text, slots: activeGroup[timeUnit] ?? []).sortedBy((s) => s.room)) SizedBox( key: ValueKey(slot), width: tileWidth, diff --git a/lib/src/slots/presentation/screens/slot_overview_screen.dart b/lib/src/slots/presentation/screens/slot_overview_screen.dart index e1d94181..60bbe9e4 100644 --- a/lib/src/slots/presentation/screens/slot_overview_screen.dart +++ b/lib/src/slots/presentation/screens/slot_overview_screen.dart @@ -1,19 +1,32 @@ import 'dart:collection'; import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:data_widget/data_widget.dart'; import 'package:eduplanner/eduplanner.dart'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:intl/intl.dart'; /// Displays an overview of all slots for a supervisor. -class SlotOverviewScreen extends StatelessWidget with AdaptiveWidget, NoMobile { +class SlotOverviewScreen extends StatefulWidget { /// Displays an overview of all slots for a supervisor. const SlotOverviewScreen({super.key}); /// The date formatter. static final formatter = DateFormat('dd.MM.yyyy'); + @override + State createState() => _SlotOverviewScreenState(); +} + +class _SlotOverviewScreenState extends State with AdaptiveState, NoMobile { + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + Data.of(context).setTrailingWidget(const SlotsViewSwitcher()); + } + @override Widget buildDesktop(BuildContext context) { final slots = context.watch(); @@ -25,47 +38,50 @@ class SlotOverviewScreen extends StatelessWidget with AdaptiveWidget, NoMobile { return Padding( padding: PaddingAll(), - child: SingleChildScrollView( - child: Column( - spacing: Spacing.largeSpacing, - children: [ - for (final group in groups.entries) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${group.key.translate(context)} ${formatter.format(group.key.nextDate)}', - style: context.theme.textTheme.titleLarge, - ), - Spacing.mediumVertical(), - for (final timespan in group.value.entries) - Column( - children: [ - Row( - children: [ - const Icon(Icons.access_time), - Spacing.xsHorizontal(), - Text('${timespan.key.$1.humanReadable()} - ${timespan.key.$2.humanReadable()}'), - ], - ), - Spacing.smallVertical(), - Wrap( - spacing: Spacing.mediumSpacing, - runSpacing: Spacing.mediumSpacing, - children: [ - for (final slot in timespan.value) - SizedBox( - key: ValueKey(slot), - width: 300, - child: SlotOverviewWidget(slot: slot), - ), - ], - ).stretch(), - ], - ).paddingOnly(bottom: Spacing.largeSpacing), - ], - ), - ], + child: Align( + alignment: Alignment.topLeft, + child: SingleChildScrollView( + child: Column( + spacing: Spacing.largeSpacing, + children: [ + for (final group in groups.entries) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${group.key.translate(context)} ${SlotOverviewScreen.formatter.format(group.key.nextDate)}', + style: context.theme.textTheme.titleLarge, + ), + Spacing.mediumVertical(), + for (final timespan in group.value.entries) + Column( + children: [ + Row( + children: [ + const Icon(Icons.access_time), + Spacing.xsHorizontal(), + Text('${timespan.key.$1.humanReadable()} - ${timespan.key.$2.humanReadable()}'), + ], + ), + Spacing.smallVertical(), + Wrap( + spacing: Spacing.mediumSpacing, + runSpacing: Spacing.mediumSpacing, + children: [ + for (final slot in timespan.value) + SizedBox( + key: ValueKey(slot), + width: 300, + child: SlotOverviewWidget(slot: slot), + ), + ], + ).stretch(), + ], + ).paddingOnly(bottom: Spacing.largeSpacing), + ], + ), + ], + ), ), ), ); diff --git a/lib/src/slots/presentation/screens/slot_reservation_screen.dart b/lib/src/slots/presentation/screens/slot_reservation_screen.dart index 70ba814d..85d16d51 100644 --- a/lib/src/slots/presentation/screens/slot_reservation_screen.dart +++ b/lib/src/slots/presentation/screens/slot_reservation_screen.dart @@ -1,6 +1,7 @@ import 'dart:collection'; import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:data_widget/data_widget.dart'; import 'package:eduplanner/eduplanner.dart'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; @@ -9,13 +10,25 @@ import 'package:intl/intl.dart'; import 'package:sliver_tools/sliver_tools.dart'; /// A screen for reserving slots. -class SlotReservationScreen extends StatelessWidget with AdaptiveWidget { +class SlotReservationScreen extends StatefulWidget { /// A screen for reserving slots. const SlotReservationScreen({super.key}); /// The date formatter. static final formatter = DateFormat('dd.MM.yyyy'); + @override + State createState() => _SlotReservationScreenState(); +} + +class _SlotReservationScreenState extends State with AdaptiveState { + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + Data.of(context).setTrailingWidget(const SlotsViewSwitcher()); + } + @override Widget buildDesktop(BuildContext context) { final slots = context.watch(); @@ -41,7 +54,7 @@ class SlotReservationScreen extends StatelessWidget with AdaptiveWidget { Padding( padding: PaddingLeft(), child: Text( - '${group.key.translate(context)} ${formatter.format(group.key.nextDate)}', + '${group.key.translate(context)} ${SlotReservationScreen.formatter.format(group.key.nextDate)}', style: context.theme.textTheme.titleMedium, ).bold(), ), @@ -107,7 +120,7 @@ class SlotReservationScreen extends StatelessWidget with AdaptiveWidget { padding: PaddingLeft().Vertical(Spacing.smallSpacing), color: context.theme.scaffoldBackgroundColor, child: Text( - '${group.key.translate(context)} ${formatter.format(group.key.nextDate)}', + '${group.key.translate(context)} ${SlotReservationScreen.formatter.format(group.key.nextDate)}', style: context.theme.textTheme.titleMedium, ).bold(), ).show(stagger), diff --git a/lib/src/slots/presentation/widgets/mapping_widget.dart b/lib/src/slots/presentation/widgets/mapping_widget.dart new file mode 100644 index 00000000..002bb895 --- /dev/null +++ b/lib/src/slots/presentation/widgets/mapping_widget.dart @@ -0,0 +1,27 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:eduplanner/src/moodle/moodle.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_utils/flutter_utils.dart'; + +class MappingWidget extends StatelessWidget { + const MappingWidget({super.key, required this.course, required this.vintage}); + + final MoodleCourse course; + + final Vintage vintage; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + CourseTag(course: course), + Spacing.xsHorizontal(), + Text(course.name, overflow: TextOverflow.ellipsis).flexible(flex: 3), + Spacing.smallHorizontal(), + Text(vintage.humanReadable, overflow: TextOverflow.ellipsis).flexible(flex: 1), + // Spacing.mediumHorizontal(), + ], + ); + } +} diff --git a/lib/src/slots/presentation/widgets/slot_data_pop_over.dart b/lib/src/slots/presentation/widgets/slot_data_pop_over.dart new file mode 100644 index 00000000..be33b205 --- /dev/null +++ b/lib/src/slots/presentation/widgets/slot_data_pop_over.dart @@ -0,0 +1,119 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:eduplanner/eduplanner.dart'; +import 'package:flutter/material.dart'; +import 'package:popover/popover.dart'; + +class SlotDataPopOver extends StatefulWidget { + const SlotDataPopOver({super.key, required this.contentList}); + + final List contentList; + + @override + State createState() => _SlotDataPopOverState(); +} + +class _SlotDataPopOverState extends State { + BuildContext? popupContext; + bool parentHover = true; + bool childHover = true; + + void closePopUp({bool forceClose = false}) { + if (popupContext == null) return; + + if ((parentHover || childHover) && !forceClose) return; + + Navigator.of(popupContext!).pop(); + + popupContext = null; + } + + @override + Widget build(BuildContext context) { + return HoverBuilder( + cursor: SystemMouseCursors.basic, + builder: (context, isHovering) { + parentHover = isHovering; + Future.delayed(const Duration(milliseconds: 300), closePopUp); + if (widget.contentList.isEmpty) { + closePopUp(forceClose: true); + return const SizedBox.shrink(); + } + return Row( + children: [ + Expanded( + child: widget.contentList[0], + ), + if (widget.contentList.length > 1) ...[ + Spacing.xsHorizontal(), + GestureDetector( + // ignore: sort_child_properties_last + child: Chip( + label: Text('+${widget.contentList.length - 1}'), + ), + onTap: widget.contentList.length > 1 + ? () { + if (popupContext != null) { + closePopUp(forceClose: true); + return; + } + showPopover( + context: context, + bodyBuilder: (context) { + popupContext = context; + return HoverBuilder( + cursor: SystemMouseCursors.basic, + builder: (context, isHovering) { + childHover = isHovering; + Future.delayed(const Duration(milliseconds: 300), closePopUp); + return Card( + elevation: 16, + shape: squircle(side: BorderSide(color: context.theme.dividerColor)), + color: context.theme.cardColor, + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: widget.contentList.spaced(Spacing.smallSpacing), + ), + ), + ), + ); + }, + ); + }, + arrowHeight: 0, + arrowWidth: 0, + allowClicksOnBackground: true, + direction: PopoverDirection.bottom, + transition: PopoverTransition.other, + // contentDxOffset: -width + (context.size?.width ?? 0), + barrierDismissible: true, + barrierColor: Colors.transparent, + backgroundColor: Colors.transparent, + transitionDuration: const Duration(milliseconds: 300), + popoverTransitionBuilder: (animation, child) { + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: animation.drive( + Tween( + begin: const Offset(0, -0.02), + end: Offset.zero, + ), + ), + child: child, + ), + ); + }, + ); + } + : null, + ), + ], + ], + ); + }, + ); + } +} diff --git a/lib/src/slots/presentation/widgets/slot_master_widget.dart b/lib/src/slots/presentation/widgets/slot_master_widget.dart index 1daabb93..dd2686f6 100644 --- a/lib/src/slots/presentation/widgets/slot_master_widget.dart +++ b/lib/src/slots/presentation/widgets/slot_master_widget.dart @@ -1,5 +1,4 @@ import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:carousel_slider/carousel_slider.dart'; import 'package:eduplanner/eduplanner.dart'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; @@ -76,7 +75,7 @@ class _SlotMasterWidgetState extends State { final courseVintage = widget.slot.mappings .map((m) { - final course = courseRepository.filter(id: m.courseId).firstOrNull; + final course = courseRepository.getById(m.courseId); final vintage = m.vintage; return (course, vintage); @@ -121,15 +120,8 @@ class _SlotMasterWidgetState extends State { ), Padding( padding: PaddingLeft(Spacing.mediumSpacing), - child: CarouselSlider( - disableGesture: true, - options: CarouselOptions( - autoPlay: true, - enableInfiniteScroll: false, - height: 50, - scrollDirection: Axis.vertical, - ), - items: [ + child: SlotDataPopOver( + contentList: [ for (final supervisor in supervisors) UserWidget(user: supervisor), ], ), @@ -143,30 +135,10 @@ class _SlotMasterWidgetState extends State { ), Padding( padding: PaddingLeft(Spacing.mediumSpacing), - child: CarouselSlider( - disableGesture: true, - options: CarouselOptions( - autoPlay: true, - enableInfiniteScroll: false, - scrollDirection: Axis.vertical, - height: 50, - ), - items: [ - for (final (course, vintage) in courseVintage) - Row( - children: [ - CourseTag(course: course!), - Spacing.xsHorizontal(), - Text(course.name), - Spacing.smallHorizontal(), - Text(vintage.humanReadable), - Spacing.mediumHorizontal(), - ], - ), - ], + child: SlotDataPopOver( + contentList: [for (final (course, vintage) in courseVintage) MappingWidget(course: course!, vintage: vintage)], ), ).expanded(), - // IconButton(onPressed: deleteSlot, icon: Icon(Icons.delete)) Row( mainAxisAlignment: MainAxisAlignment.end, children: [ diff --git a/lib/src/slots/presentation/widgets/slot_overview_widget.dart b/lib/src/slots/presentation/widgets/slot_overview_widget.dart index 0fd32d45..b6821ac1 100644 --- a/lib/src/slots/presentation/widgets/slot_overview_widget.dart +++ b/lib/src/slots/presentation/widgets/slot_overview_widget.dart @@ -1,5 +1,4 @@ import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:carousel_slider/carousel_slider.dart'; import 'package:eduplanner/eduplanner.dart'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; @@ -27,7 +26,7 @@ class _SlotOverviewWidgetState extends State { final courseVintage = widget.slot.mappings .map((m) { - final course = courseRepository.filter(id: m.courseId).firstOrNull; + final course = courseRepository.getById(m.courseId); final vintage = m.vintage; return (course, vintage); @@ -73,29 +72,8 @@ class _SlotOverviewWidgetState extends State { Text(context.t.slots_details_mappingsCount(courseVintage.length)), ], ), - CarouselSlider( - disableGesture: true, - options: CarouselOptions( - autoPlay: true, - enableInfiniteScroll: false, - height: 40, - scrollDirection: Axis.vertical, - ), - items: [ - for (final (course, vintage) in courseVintage) - Row( - children: [ - CourseTag(course: course!), - Spacing.xsHorizontal(), - Text( - course.name, - overflow: TextOverflow.ellipsis, - ).expanded(flex: 3), - Spacing.smallHorizontal(), - Text(vintage.humanReadable).expanded(), - ], - ), - ], + SlotDataPopOver( + contentList: [for (final (course, vintage) in courseVintage) MappingWidget(course: course!, vintage: vintage)], ), ], ), diff --git a/lib/src/slots/presentation/widgets/slot_widget.dart b/lib/src/slots/presentation/widgets/slot_widget.dart index 838b880e..c44c873f 100644 --- a/lib/src/slots/presentation/widgets/slot_widget.dart +++ b/lib/src/slots/presentation/widgets/slot_widget.dart @@ -285,7 +285,7 @@ class _SlotWidgetState extends State with AdaptiveState { for (final supervisor in supervisors) UserWidget( user: supervisor, - expand: true, + flexible: true, ), ], ).expanded(), diff --git a/lib/src/slots/presentation/widgets/slots_view_switcher.dart b/lib/src/slots/presentation/widgets/slots_view_switcher.dart new file mode 100644 index 00000000..09bf0a58 --- /dev/null +++ b/lib/src/slots/presentation/widgets/slots_view_switcher.dart @@ -0,0 +1,105 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:eduplanner/eduplanner.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:flutter_vector_icons/flutter_vector_icons.dart'; + +/// Provides a [DropdownMenu] that allows the user to switch between different slot views based on their [UserCapability]s. +/// +/// If there is <=1 options this returns [SizedBox.shrink]. +class SlotsViewSwitcher extends StatelessWidget { + /// Provides a [DropdownMenu] that allows the user to switch between different slot views based on their [UserCapability]s. + /// + /// If there is <=1 options this returns [SizedBox.shrink]. + const SlotsViewSwitcher({super.key}); + + @override + Widget build(BuildContext context) { + final user = context.watch(); + + final currentRoute = Modular.to.path; + final capabilities = user.state.data?.capabilities.toList() ?? []; + + if (capabilities.length <= 1) return const SizedBox.shrink(); + + return DropdownMenu( + inputDecorationTheme: context.theme.dropdownMenuTheme.inputDecorationTheme + ?.copyWith(fillColor: context.theme.cardColor, contentPadding: PaddingHorizontal(Spacing.smallSpacing)), + dropdownMenuEntries: [ + for (final r in capabilities) + DropdownMenuEntry( + value: r, + label: r.translateSlotRoute(context), + labelWidget: Text(r.translateSlotRoute(context)), + leadingIcon: Icon(r.slotIcon, size: 16), + ), + ], + + trailingIcon: const Icon( + FontAwesome5Solid.chevron_down, + size: 13, + ), + leadingIcon: Icon( + SlotUserCapabilityX.capabilityFromRoute(currentRoute).slotIcon, + size: 16, + ), + requestFocusOnTap: false, // disable text input + initialSelection: SlotUserCapabilityX.capabilityFromRoute(currentRoute), + onSelected: (r) { + if (r == null) return; + + /// Do not use `navigate` as this will replace all loaded repositories. + Modular.to.pushNamed(r.slotRoute); + }, + ); + } +} + +/// Extension on [UserCapability] for [SlotsViewSwitcher] to determine routes based on capabilities. +extension SlotUserCapabilityX on UserCapability { + /// Returns the route (from root) to the screen slot screen linked to this capability. + String get slotRoute { + return switch (this) { + UserCapability.teacher => '/slots/overview/', + UserCapability.student => '/slots/book/', + UserCapability.slotMaster => '/slots/', + }; + } + + /// Returns the [UserCapability] linked to the given [route]. + /// + /// If no matching capability is found this throws. + static UserCapability capabilityFromRoute(String route) { + if (route.startsWith(UserCapability.teacher.slotRoute)) { + return UserCapability.teacher; + } + if (route.startsWith(UserCapability.student.slotRoute)) { + return UserCapability.student; + } + if (route.startsWith(UserCapability.slotMaster.slotRoute)) { + return UserCapability.slotMaster; + } + + throw ArgumentError.value(route, 'route', 'Unkown route'); + } + + /// Returns a human readable name for the items in the dropdown menu. + String translateSlotRoute(BuildContext context) { + // TODO(mastermarcohd): localize values + + return switch (this) { + UserCapability.teacher => 'View Reservations', + UserCapability.student => 'Book Slots', + UserCapability.slotMaster => 'Manage Slots', + }; + } + + /// The icon used to represent this view in the dropdown. + IconData get slotIcon { + return switch (this) { + UserCapability.teacher => FontAwesome5Solid.chalkboard_teacher, + UserCapability.student => FontAwesome5Solid.book, + UserCapability.slotMaster => FontAwesome5Solid.cogs, + }; + } +} diff --git a/lib/src/slots/presentation/widgets/widgets.dart b/lib/src/slots/presentation/widgets/widgets.dart index 67104764..0cedd767 100644 --- a/lib/src/slots/presentation/widgets/widgets.dart +++ b/lib/src/slots/presentation/widgets/widgets.dart @@ -1,5 +1,8 @@ export 'edit_slot_dialog.dart'; +export 'mapping_widget.dart'; export 'number_spinner.dart'; +export 'slot_data_pop_over.dart'; export 'slot_master_widget.dart'; export 'slot_overview_widget.dart'; export 'slot_widget.dart'; +export 'slots_view_switcher.dart'; diff --git a/lib/src/theming/infra/services/material_theme_generator_service.dart b/lib/src/theming/infra/services/material_theme_generator_service.dart index 063c59f2..07a70aca 100644 --- a/lib/src/theming/infra/services/material_theme_generator_service.dart +++ b/lib/src/theming/infra/services/material_theme_generator_service.dart @@ -166,7 +166,7 @@ class MaterialThemeGeneratorService extends ThemeGeneratorService { menuStyle: MenuStyle( padding: WidgetStatePropertyAll(PaddingAll(Spacing.smallSpacing).Vertical(Spacing.mediumSpacing)), backgroundColor: WidgetStatePropertyAll(themeBase.secondaryColor), - shape: WidgetStatePropertyAll(squircle()), + shape: WidgetStatePropertyAll(squircle(side: BorderSide(color: themeBase.tertiaryColor))), elevation: const WidgetStatePropertyAll(8), ), ), diff --git a/pubspec.lock b/pubspec.lock index fb69a662..a156c9d9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -77,10 +77,10 @@ packages: dependency: "direct main" description: name: awesome_extensions - sha256: d61c85a583c753e106fcbff392c705c8cd72f6fcacc86ddd1bdcc0a6f498efb3 + sha256: "41eecb104e84df80f7f51d01cefb4c4046dc5ca40c2f4379745a472140e79fad" url: "https://pub.dev" source: hosted - version: "2.0.25" + version: "2.0.26" bloc: dependency: "direct main" description: @@ -165,10 +165,10 @@ packages: dependency: transitive description: name: built_value - sha256: ba95c961bafcd8686d1cf63be864eb59447e795e124d98d6a27d91fcd13602fb + sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d url: "https://pub.dev" source: hosted - version: "8.11.1" + version: "8.12.0" carousel_slider: dependency: "direct main" description: @@ -229,10 +229,10 @@ packages: dependency: transitive description: name: code_builder - sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" + sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243" url: "https://pub.dev" source: hosted - version: "4.10.1" + version: "4.11.0" collection: dependency: "direct main" description: @@ -514,10 +514,10 @@ packages: dependency: "direct main" description: name: flutter_svg - sha256: cd57f7969b4679317c17af6fd16ee233c1e60a82ed209d8a475c54fd6fd6f845 + sha256: b9c2ad5872518a27507ab432d1fb97e8813b05f0fc693f9d40fad06d073e0678 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.2.1" flutter_test: dependency: "direct dev" description: flutter @@ -528,7 +528,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: "76cb50a736e826936f4678eeee2b0b04614094a7" + resolved-ref: be90927b2e5d2f04ce0398de99a532fccfb85600 url: "https://github.com/mcquenji/flutter_utils.git" source: git version: "0.0.2" @@ -557,10 +557,10 @@ packages: dependency: "direct main" description: name: font_awesome_flutter - sha256: b738e35f8bb4957896c34957baf922f99c5d415b38ddc8b070d14b7fa95715d4 + sha256: "27af5982e6c510dec1ba038eff634fa284676ee84e3fd807225c80c4ad869177" url: "https://pub.dev" source: hosted - version: "10.9.1" + version: "10.10.0" freezed: dependency: "direct dev" description: @@ -725,10 +725,10 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "11.0.1" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: @@ -985,10 +985,10 @@ packages: dependency: transitive description: name: pool - sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" url: "https://pub.dev" source: hosted - version: "1.5.1" + version: "1.5.2" popover: dependency: "direct main" description: @@ -1155,10 +1155,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: a2608114b1ffdcbc9c120eb71a0e207c71da56202852d4aab8a5e30a82269e74 + sha256: "0b0f98d535319cb5cdd4f65783c2a54ee6d417a2f093dbb18be3e36e4c3d181f" url: "https://pub.dev" source: hosted - version: "2.4.12" + version: "2.4.14" shared_preferences_foundation: dependency: transitive description: @@ -1344,10 +1344,10 @@ packages: dependency: transitive description: name: system_info2 - sha256: "65206bbef475217008b5827374767550a5420ce70a04d2d7e94d1d2253f3efc9" + sha256: b937736ecfa63c45b10dde1ceb6bb30e5c0c340e14c441df024150679d65ac43 url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.1.0" term_glyph: dependency: transitive description: @@ -1440,10 +1440,10 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "69ee86740f2847b9a4ba6cffa74ed12ce500bbe2b07f3dc1e643439da60637b7" + sha256: c0fb544b9ac7efa10254efaf00a951615c362d1ea1877472f8f6c0fa00fcf15b url: "https://pub.dev" source: hosted - version: "6.3.18" + version: "6.3.23" url_launcher_ios: dependency: transitive description: @@ -1544,18 +1544,18 @@ packages: dependency: transitive description: name: vm_service - sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" url: "https://pub.dev" source: hosted - version: "15.0.0" + version: "15.0.2" watcher: dependency: transitive description: name: watcher - sha256: "5bf046f41320ac97a469d506261797f35254fa61c641741ef32dacda98b7d39c" + sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a" url: "https://pub.dev" source: hosted - version: "1.1.3" + version: "1.1.4" web: dependency: transitive description: