diff --git a/lib/app_router.dart b/lib/app_router.dart index b3dab4c1..9056960d 100644 --- a/lib/app_router.dart +++ b/lib/app_router.dart @@ -1,7 +1,10 @@ +import 'package:flutter/material.dart'; +import 'package:lb_planner/features/feedback/presentation/screens/feedback_page.dart'; import 'package:lb_planner/features/themes/themes.dart'; import 'package:lb_planner/shared/shared.dart'; import 'package:auto_route/auto_route.dart'; import 'package:lb_planner/features/auth/auth.dart'; +import 'package:lb_planner/features/feedback/presentation/screens/feedback.dart'; part 'app_router.gr.dart'; @@ -109,15 +112,18 @@ class AppRouter extends _$AppRouter { path: '/theme-development', ), DefaultRoute( - page: LoginRoute.page, - path: '/login', - ) + page: AdminFeedbacksRoute.page, + path: '/feedback', + initial: true, + title: (context, data) => context.t.admin_feedback_routeName, + ), ]; } /// Implements [CustomRoute] with some default settings. class DefaultRoute extends CustomRoute { /// Implements [CustomRoute] with some default settings. - DefaultRoute({required super.page, required super.path, super.initial}) + DefaultRoute( + {required super.page, required super.path, super.initial, super.title}) : super(transitionsBuilder: TransitionsBuilders.noTransition); } diff --git a/lib/app_router.gr.dart b/lib/app_router.gr.dart index 39def6dc..4cf5a02e 100644 --- a/lib/app_router.gr.dart +++ b/lib/app_router.gr.dart @@ -15,6 +15,22 @@ abstract class _$AppRouter extends RootStackRouter { @override final Map pagesMap = { + AdminFeedbackRoute.name: (routeData) { + final args = routeData.argsAs(); + return AutoRoutePage( + routeData: routeData, + child: AdminFeedbackScreen( + key: args.key, + feedbackId: args.feedbackId, + ), + ); + }, + AdminFeedbacksRoute.name: (routeData) { + return AutoRoutePage( + routeData: routeData, + child: const AdminFeedbacksScreen(), + ); + }, LoginRoute.name: (routeData) { return AutoRoutePage( routeData: routeData, @@ -30,6 +46,58 @@ abstract class _$AppRouter extends RootStackRouter { }; } +/// generated route for +/// [AdminFeedbackScreen] +class AdminFeedbackRoute extends PageRouteInfo { + AdminFeedbackRoute({ + Key? key, + required int feedbackId, + List? children, + }) : super( + AdminFeedbackRoute.name, + args: AdminFeedbackRouteArgs( + key: key, + feedbackId: feedbackId, + ), + initialChildren: children, + ); + + static const String name = 'AdminFeedbackRoute'; + + static const PageInfo page = + PageInfo(name); +} + +class AdminFeedbackRouteArgs { + const AdminFeedbackRouteArgs({ + this.key, + required this.feedbackId, + }); + + final Key? key; + + final int feedbackId; + + @override + String toString() { + return 'AdminFeedbackRouteArgs{key: $key, feedbackId: $feedbackId}'; + } +} + +/// generated route for +/// [AdminFeedbacksScreen] +class AdminFeedbacksRoute extends PageRouteInfo { + const AdminFeedbacksRoute({List? children}) + : super( + AdminFeedbacksRoute.name, + initialChildren: children, + ); + + static const String name = 'AdminFeedbacksRoute'; + + static const PageInfo page = PageInfo(name); +} + /// generated route for /// [LoginScreen] class LoginRoute extends PageRouteInfo { diff --git a/lib/features/auth/domain/models/user.dart b/lib/features/auth/domain/models/user.dart index 491cd789..e4a88eec 100644 --- a/lib/features/auth/domain/models/user.dart +++ b/lib/features/auth/domain/models/user.dart @@ -85,6 +85,9 @@ class User with _$User { bool hasCapability(UserCapability capability) => capabilities.contains(capability); + /// Returns the full name of the user. + String get fullname => '$firstname $lastname'; + /// Returns `true` if this user has elevated privileges (i.e. [UserCapability.dev] or [UserCapability.moderator]). Otherwise `false`. bool get isElevated => hasCapability(UserCapability.dev) || diff --git a/lib/features/feedback/domain/providers/feedback_provider_state.dart b/lib/features/feedback/domain/providers/feedback_provider_state.dart index 5167d232..a5647a9b 100644 --- a/lib/features/feedback/domain/providers/feedback_provider_state.dart +++ b/lib/features/feedback/domain/providers/feedback_provider_state.dart @@ -95,4 +95,17 @@ class FeedbackProviderState extends AutoRefreshAsyncNotifier> { return true; }).toList(); } + + /// Returns the [Feedback] with the given [id]. + /// + /// If no feedback with the specified [id] can be found or [state.hasValue] is `false` this method will throw a [StateError]. + Feedback getFeedbackById(int id) { + if (!state.hasValue) throw StateError("State is ${state.runtimeType}!"); + + final feedback = state.requireValue.firstWhereOrNull((e) => e.id == id); + + if (feedback == null) throw StateError("Feedback with id $id not found!"); + + return feedback; + } } diff --git a/lib/features/feedback/presentation/presentation.dart b/lib/features/feedback/presentation/presentation.dart new file mode 100644 index 00000000..998a0979 --- /dev/null +++ b/lib/features/feedback/presentation/presentation.dart @@ -0,0 +1,2 @@ +export 'widgets/widgets.dart'; +export 'screens/screens.dart'; diff --git a/lib/features/feedback/presentation/screens/feedback.dart b/lib/features/feedback/presentation/screens/feedback.dart new file mode 100644 index 00000000..5cceb3e8 --- /dev/null +++ b/lib/features/feedback/presentation/screens/feedback.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart' hide Feedback; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lb_planner/features/feedback/presentation/presentation.dart'; +import 'package:lb_planner/shared/shared.dart'; +import 'package:lb_planner/features/feedback/domain/domain.dart'; +import 'package:lb_planner/features/feedback/presentation/widgets/widgets.dart'; + +@RoutePage() + +/// Shows all feedbacks for the admin. +class AdminFeedbacksScreen extends StatefulWidget { + /// Shows all feedbacks for the admin. + const AdminFeedbacksScreen({Key? key}) : super(key: key); + + /// The font size of the header. + static const double headerFontSize = 20; + + @override + State createState() => _AdminFeedbacksScreenState(); + + /// Sorts the given feedback list + static List? sortFeedbacks(AsyncValue> feedbacks) { + feedbacks.value?.sort( + (a, b) { + var status = a.readAsInt.compareTo(b.readAsInt); + + if (status != 0) return status; + + var timestamp = b.createdAt.compareTo(a.createdAt); + + return timestamp; + }, + ); + + return feedbacks.value; + } +} + +class _AdminFeedbacksScreenState extends State { + @override + Widget build(BuildContext context) { + return Material( + child: Sidebar( + body: Consumer(builder: (context, ref, _) { + var feedbacks = ref.watch(feedbackProvider); + + var sortedFeedbacks = AdminFeedbacksScreen.sortFeedbacks(feedbacks); + + return ConditionalWidget( + ifFalse: Center( + child: Text( + t.admin_feedback_noFeedback, + style: TextStyle( + overflow: TextOverflow.ellipsis, + fontSize: AdminFeedbacksScreen.headerFontSize, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.left, + ), + ), + condition: sortedFeedbacks?.isNotEmpty ?? false, + ifTrue: Align( + alignment: Alignment.topLeft, + child: Column( + children: [ + Row( + children: [ + Expanded( + child: Text( + t.admin_feedback_headers_user, + style: TextStyle( + overflow: TextOverflow.ellipsis, + fontSize: AdminFeedbacksScreen.headerFontSize, + fontWeight: FontWeight.w600, + ), + ), + ), + Expanded( + child: Text( + t.admin_feedback_headers_status, + textAlign: TextAlign.center, + style: TextStyle( + overflow: TextOverflow.ellipsis, + fontSize: AdminFeedbacksScreen.headerFontSize, + fontWeight: FontWeight.w600, + ), + ), + ), + Expanded( + child: Text( + t.admin_feedback_headers_type, + textAlign: TextAlign.center, + style: TextStyle( + overflow: TextOverflow.ellipsis, + fontSize: AdminFeedbacksScreen.headerFontSize, + fontWeight: FontWeight.w600, + ), + ), + ), + Expanded( + child: Text( + t.admin_feedback_headers_lastModified, + textAlign: TextAlign.center, + style: TextStyle( + overflow: TextOverflow.ellipsis, + fontSize: AdminFeedbacksScreen.headerFontSize, + fontWeight: FontWeight.w600, + ), + ), + ), + Expanded( + child: Text( + t.admin_feedback_headers_lastModifiedBy, + textAlign: TextAlign.center, + style: TextStyle( + overflow: TextOverflow.ellipsis, + fontSize: AdminFeedbacksScreen.headerFontSize, + fontWeight: FontWeight.w600, + ), + ), + ), + Expanded( + child: Text( + t.admin_feedback_headers_timestamp, + textAlign: TextAlign.center, + style: TextStyle( + overflow: TextOverflow.ellipsis, + fontSize: AdminFeedbacksScreen.headerFontSize, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + Spacing.large(), + Expanded( + child: ListView( + controller: ScrollController(), + children: [ + if (sortedFeedbacks != null) + for (var feedback in sortedFeedbacks) ...[ + AdminFeedbackItem(feedbackId: feedback.id), + Spacing.medium(), + ], + ], + ), + ), + ], + ), + ), + ); + }), + ), + ); + } +} diff --git a/lib/features/feedback/presentation/screens/feedback_page.dart b/lib/features/feedback/presentation/screens/feedback_page.dart new file mode 100644 index 00000000..617451f5 --- /dev/null +++ b/lib/features/feedback/presentation/screens/feedback_page.dart @@ -0,0 +1,233 @@ +import 'package:flutter/material.dart' hide Feedback; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_vector_icons/flutter_vector_icons.dart'; +import 'package:lb_planner/features/feedback/domain/domain.dart'; +import 'package:lb_planner/features/feedback/presentation/screens/feedback.dart'; +import 'package:lb_planner/features/themes/domain/models/module_status_theme.dart'; +import 'package:lb_planner/shared/shared.dart'; +import 'package:lb_planner/features/feedback/presentation/widgets/widgets.dart'; + +@RoutePage() + +/// Displays details about feedback. +class AdminFeedbackScreen extends StatefulWidget { + /// Displays details about feedback. + const AdminFeedbackScreen({Key? key, required this.feedbackId}) + : super(key: key); + + /// The id of the feedback to display. + final int feedbackId; + + @override + State createState() => _AdminFeedbackScreenState(); +} + +class _AdminFeedbackScreenState extends State { + final commentController = TextEditingController(); + + late int updated; + late int deleted; + + _updateFeedback(WidgetRef ref, Feedback feedback) async { + final controller = ref.watch(feedbackController); + + if (feedback.unread) { + controller.markFeedbackAsRead(feedback, comment: commentController.text); + updated = 1; + } + + updated = 0; + + _pushNext(context, ref, feedback); + } + + _pushNext(BuildContext context, WidgetRef ref, feedback) { + var sorted = AdminFeedbacksScreen.sortFeedbacks(ref.read(feedbackProvider)); + var currentIndex = sorted?.indexOf(feedback); + + if (sorted!.length - 1 > currentIndex! && currentIndex >= 0) { + // If there's a next item, navigate to the next feedback detail page + var nextFeedback = sorted[currentIndex + 1]; + context.router.push(AdminFeedbackRoute( + feedbackId: nextFeedback.id, + key: ValueKey(nextFeedback.id), + )); + } else { + // If there's no next item, navigate to the admin feedback list page + + context.router.push(AdminFeedbacksRoute()); + } + } + + _deleteFeedback( + BuildContext context, WidgetRef ref, Feedback feedbackToDelete) async { + ref.read(feedbackController).deleteFeedback(feedbackToDelete); + deleted = 1; + + deleted = 0; + + _pushNext(context, ref, ref.read(feedbackProvider)); + } + + @override + Widget build(BuildContext context) { + bool _commenmtInit = false; + + return Material( + child: Sidebar( + body: Consumer( + builder: (context, ref, _) { + int feedbackId = widget.feedbackId; + + var controller = ref.watch(feedbackController); + var feedback = controller.getFeedbackById(feedbackId); + + if (ref.read(feedbackProvider).isLoading) { + return ShimmerEffect(height: AdminFeedbackItem.height); + } + + if (!_commenmtInit) { + commentController.text = feedback.comment; + _commenmtInit = true; + } + + int user = feedback.author; + + return LpContainer( + title: feedback.type.title(context), //swtich jsonvalues + leading: + Icon(feedback.type.icon, color: feedback.type.color(context)), + trailing: ConditionalWidget( + condition: deleted == 0, + ifTrue: HoverBuilder( + builder: (context, hover) => Icon( + Icons.delete, + color: hover + ? context.theme.colorScheme.error + : ModuleStatusTheme.of(context).pendingColor, + ), + onTap: () => showConfirmDialog( + context, + title: t.admin_feedback_page_deleteTitle, + message: t.admin_feedback_page_deleteText, + confirmIsBad: true, + onConfirm: () => _deleteFeedback(context, ref, feedback), + ), + ), + ifFalse: CircularProgressIndicator( + color: context.theme.colorScheme.error), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Spacing.small(), + FeedbackStatusTag(read: feedback.read), + Spacing.small(), + Text( + t.admin_feedback_page_author(user as String), + style: TextStyle( + fontWeight: FontWeight.w600, + overflow: TextOverflow.ellipsis, + fontSize: AdminFeedbackItem.fontSize, + ), + textAlign: TextAlign.left, + ), + if (feedback.type == FeedbackType.bug) + Text( + t.admin_feedback_page_logFile(feedback.logfile!), + style: TextStyle( + fontWeight: FontWeight.w600, + overflow: TextOverflow.ellipsis, + fontSize: AdminFeedbackItem.fontSize, + ), + textAlign: TextAlign.left, + ), + Text( + t.admin_feedback_page_id(feedback.id), + style: TextStyle( + fontWeight: FontWeight.w600, + overflow: TextOverflow.ellipsis, + fontSize: AdminFeedbackItem.fontSize, + ), + textAlign: TextAlign.left, + ), + Spacing.large(), + Expanded( + child: CustomScrollView( + controller: ScrollController(), + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + feedback.content, + style: TextStyle( + fontWeight: FontWeight.normal, + overflow: TextOverflow.ellipsis, + fontSize: AdminFeedbackItem.fontSize, + ), + textAlign: TextAlign.left, + ), + ), + ], + ), + ), + ], + ), + ), + Spacing.large(), + Expanded( + child: TextField( + keyboardType: TextInputType.multiline, + controller: commentController, + decoration: InputDecoration( + border: OutlineInputBorder(), + hintText: t.admin_feedback_page_comment, + )), + ), + Spacing.large(), + Align( + alignment: Alignment.centerRight, + child: ConditionalWidget( + condition: updated != 0, + ifTrue: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + feedback.read + ? t.admin_feedback_page_update + : t.admin_feedback_page_markRead, + style: TextStyle( + fontWeight: FontWeight.w600, + overflow: TextOverflow.ellipsis, + fontSize: AdminFeedbackItem.fontSize, + ), + textAlign: TextAlign.left, + ), + Spacing.small(), + CircularProgressIndicator(), + ], + ), + ifFalse: TextButton.icon( + label: Text(feedback.read + ? t.admin_feedback_page_update + : t.admin_feedback_page_markRead), + onPressed: () => _updateFeedback(ref, feedback), + icon: Icon(Feather.arrow_right_circle, + size: AdminFeedbackItem.fontSize * 1.2, + color: context.theme.colorScheme.primary), + ), + ), + ), + ], + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/features/feedback/presentation/screens/screens.dart b/lib/features/feedback/presentation/screens/screens.dart new file mode 100644 index 00000000..37bf1997 --- /dev/null +++ b/lib/features/feedback/presentation/screens/screens.dart @@ -0,0 +1,2 @@ +export 'feedback.dart'; +export 'feedback_page.dart'; diff --git a/lib/features/feedback/presentation/widgets/feedback_item.dart b/lib/features/feedback/presentation/widgets/feedback_item.dart new file mode 100644 index 00000000..bed53b57 --- /dev/null +++ b/lib/features/feedback/presentation/widgets/feedback_item.dart @@ -0,0 +1,204 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lb_planner/features/auth/auth.dart'; +import 'package:lb_planner/features/themes/themes.dart'; +import 'package:lb_planner/shared/shared.dart'; +import 'package:lb_planner/features/feedback/domain/domain.dart'; +import 'package:lb_planner/features/feedback/presentation/presentation.dart'; +import 'package:timeago/timeago.dart' as timeago; + +/// Shows the feedback item for the admin. +class AdminFeedbackItem extends ConsumerStatefulWidget { + /// Shows the feedback item for the admin. + const AdminFeedbackItem({Key? key, required this.feedbackId}) + : super(key: key); + + /// The id of the feedback to display. + final int feedbackId; + + /// The height of the item. + static const double height = 300; + + /// The size of the user profile img. + static const double imgSize = 55; + + /// The size of the font displaying the username. + static const double usernameFontSize = 20; + + /// The size of the font. + static const double fontSize = 18; + + /// The size of the font, displaying the user tag. + static const double userTagFontSize = 17; + + @override + ConsumerState createState() => + _AdminFeedbackItemState(); +} + +class _AdminFeedbackItemState extends ConsumerState { + @override + Widget build(BuildContext context) { + int feedbackId = widget.feedbackId; + var controller = ref.watch(feedbackController); + var feedback = controller.getFeedbackById(feedbackId); + + if (ref.read(feedbackProvider).isLoading) { + return ShimmerEffect(height: AdminFeedbackItem.height); + } + + int author = feedback.author; + + int? modifyingUser = feedback.modifiedByUserId; + + return GestureDetector( + onTap: () => + context.router.push(AdminFeedbackRoute(feedbackId: feedbackId)), + child: Card( + child: Row( + children: [ + Expanded( + child: Row( + children: [ + UserProfileImg( + size: AdminFeedbackItem.imgSize, userId: author), + Spacing.small(), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + ref.read(usersProvider)[author].fullname, + style: TextStyle( + overflow: TextOverflow.fade, + fontSize: AdminFeedbackItem.usernameFontSize, + ), + textAlign: TextAlign.left, + ), + Text( + t.global_user_vintage( + ref.read(usersProvider)[author].vintage), + style: TextStyle( + overflow: TextOverflow.ellipsis, + fontSize: AdminFeedbackItem.userTagFontSize, + ), + textAlign: TextAlign.left, + ), + ], + ), + ], + ), + ), + Expanded( + child: FeedbackStatusTag( + read: feedback.read, + fontSize: AdminFeedbackItem.fontSize, + label: true, + ), + ), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + feedback.type.icon, + color: feedback.type.color(context), + size: AdminFeedbackItem.fontSize * 1.2, + ), + Spacing.xs(), + Text( + feedback.type.title(context), + style: TextStyle( + overflow: TextOverflow.ellipsis, + fontSize: AdminFeedbackItem.fontSize, + ), + textAlign: TextAlign.left, + ), + ], + ), + ), + Expanded( + child: Text( + feedback.modifiedAt != null + ? timeago.format(feedback.modifiedAt!) + : t.admin_feedback_null, + style: TextStyle( + overflow: TextOverflow.ellipsis, + fontSize: AdminFeedbackItem.fontSize, + ), + textAlign: TextAlign.center, + ), + ), + Expanded( + child: Text( + modifyingUser != null + ? ref.read(usersProvider)[modifyingUser].fullname + : t.admin_feedback_null, + style: TextStyle( + overflow: TextOverflow.ellipsis, + fontSize: AdminFeedbackItem.fontSize, + ), + textAlign: TextAlign.center, + ), + ), + Expanded( + child: Text( + timeago.format(feedback.createdAt), + style: TextStyle( + overflow: TextOverflow.ellipsis, + fontSize: AdminFeedbackItem.fontSize, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ); + } +} + +/// Helps to return the title of the feedback type. +extension AdminFeedbackItemHelper on FeedbackType { + /// Returns the title of the feedback type. + String title(BuildContext context) { + var t = context.t; + + switch (this) { + case FeedbackType.bug: + return t.admin_feedback_types_bug; + case FeedbackType.suggestion: + return t.admin_feedback_types_suggestion; + case FeedbackType.typo: + return t.admin_feedback_types_error; + case FeedbackType.other: + return t.admin_feedback_types_other; + } + } + + /// Returns the icon of the feedback type. + IconData get icon { + switch (this) { + case FeedbackType.bug: + return Icons.bug_report; + case FeedbackType.suggestion: + return Icons.lightbulb_outline; + case FeedbackType.typo: + return Icons.error; + case FeedbackType.other: + return Icons.help; + } + } + + /// Returns the color of the feedback type. + Color color(BuildContext context) { + switch (this) { + case FeedbackType.bug: + return context.theme.colorScheme.error; + case FeedbackType.other: + case FeedbackType.suggestion: + return context.theme.colorScheme.primary; + case FeedbackType.typo: + return ModuleStatusTheme.of(context).uploadedColor; + } + } +} diff --git a/lib/features/feedback/presentation/widgets/feedback_status_tag.dart b/lib/features/feedback/presentation/widgets/feedback_status_tag.dart new file mode 100644 index 00000000..4232be29 --- /dev/null +++ b/lib/features/feedback/presentation/widgets/feedback_status_tag.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:lb_planner/features/themes/themes.dart'; +import 'package:lb_planner/shared/shared.dart'; + +/// A tag based on [read] +class FeedbackStatusTag extends StatefulWidget { + /// A tag based on [read] + const FeedbackStatusTag( + {Key? key, required this.read, this.fontSize, this.label = false}) + : super(key: key); + + /// The status of the tag. + final bool read; + + /// The font size of the tag. + final double? fontSize; + + /// Whether to display the status as a label. + final bool label; + + /// THe background opacity of the tag. + static const opacity = .5; + + @override + State createState() => _FeedbackStatusTagState(); +} + +class _FeedbackStatusTagState extends State { + @override + Widget build(BuildContext context) { + var color = widget.read + ? ModuleStatusTheme.of(context).doneColor + : widget.label + ? context.theme.textTheme.bodyLarge!.color! + : ModuleStatusTheme.of(context).pendingColor; + return Container( + padding: widget.label + ? null + : const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: widget.label + ? null + : BoxDecoration( + color: color.withOpacity(0.5), + borderRadius: BorderRadius.circular(5), + ), + child: ConditionalWidget( + condition: widget.label, + ifTrue: Text( + widget.read + ? t.admin_feedback_status_read + : t.admin_feedback_status_unread, + style: TextStyle( + color: color, + fontSize: widget.fontSize, + overflow: TextOverflow.ellipsis, + ), + textAlign: TextAlign.center, + ), + ifFalse: Text( + widget.read + ? t.admin_feedback_status_read + : t.admin_feedback_status_unread, + style: TextStyle( + color: color, + fontSize: widget.fontSize, + overflow: TextOverflow.ellipsis, + fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + ), + ); + } +} diff --git a/lib/features/feedback/presentation/widgets/widgets.dart b/lib/features/feedback/presentation/widgets/widgets.dart new file mode 100644 index 00000000..9e385542 --- /dev/null +++ b/lib/features/feedback/presentation/widgets/widgets.dart @@ -0,0 +1,3 @@ +export '../screens/feedback_page.dart'; +export 'feedback_item.dart'; +export 'feedback_status_tag.dart'; diff --git a/lib/shared/presentation/widgets/color_utils.dart b/lib/shared/presentation/widgets/color_utils.dart new file mode 100644 index 00000000..5cb9e4aa --- /dev/null +++ b/lib/shared/presentation/widgets/color_utils.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +/// Utilitiy class to work with colors. +extension ColorUtils on Color { + /// Returns a new [Color] darkened by the given [amount]. + /// + /// Credit to: [NearHuscarl](https://stackoverflow.com/questions/58360989/programmatically-lighten-or-darken-a-hex-color-in-dart#:~:text=For%20people%20who%20want%20to%20darken%20or%20lighten%20Color%20instead%20of%20hex%20string) + Color darken([double amount = .1]) { + assert(amount >= 0 && amount <= 1); + + final hsl = HSLColor.fromColor(this); + final hslDark = hsl.withLightness((hsl.lightness - amount).clamp(0.0, 1.0)); + + return hslDark.toColor(); + } + + /// Returns a new [Color] lightened by the given [amount]. + /// + /// Credit to: [NearHuscarl](https://stackoverflow.com/questions/58360989/programmatically-lighten-or-darken-a-hex-color-in-dart#:~:text=For%20people%20who%20want%20to%20darken%20or%20lighten%20Color%20instead%20of%20hex%20string) + Color lighten([double amount = .1]) { + assert(amount >= 0 && amount <= 1); + + final hsl = HSLColor.fromColor(this); + final hslLight = + hsl.withLightness((hsl.lightness + amount).clamp(0.0, 1.0)); + + return hslLight.toColor(); + } +} diff --git a/lib/shared/presentation/widgets/conditional_widget.dart b/lib/shared/presentation/widgets/conditional_widget.dart new file mode 100644 index 00000000..453872a8 --- /dev/null +++ b/lib/shared/presentation/widgets/conditional_widget.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +/// Use this to conditionally swap between widgets. +class ConditionalWidget extends StatelessWidget { + /// Constructs a new [ConditionalWidget] with the given [condition], [ifTrue] and [ifFalse]. + /// If [condition] is true, [ifTrue] will be used. + /// If [condition] is false, [ifFalse] will be used. + const ConditionalWidget( + {Key? key, + required this.condition, + required this.ifTrue, + required this.ifFalse}) + : super(key: key); + + /// The condition to check. + final bool condition; + + /// The widget to show if the condition is true. + final Widget ifTrue; + + /// The widget to show if the condition is false. + final Widget ifFalse; + + @override + Widget build(BuildContext context) => condition ? ifTrue : ifFalse; +} diff --git a/lib/shared/presentation/widgets/container.dart b/lib/shared/presentation/widgets/container.dart new file mode 100644 index 00000000..ada37f8c --- /dev/null +++ b/lib/shared/presentation/widgets/container.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import 'package:lb_planner/shared/shared.dart'; + +/// Themed [Container] widget. +class LpContainer extends StatelessWidget { + /// Themed [Container] widget. + LpContainer( + {Key? key, + this.title, + this.leading, + this.trailing, + required this.child, + this.width, + this.height, + this.spacing = true}) + : super(key: key) { + window = false; + } + + /// Themed WindowContainer widget. + LpContainer.window( + {Key? key, + this.title, + this.leading, + this.trailing, + required this.child, + this.width, + this.height, + this.spacing = false}) + : super(key: key) { + window = true; + } + + /// The title of the container. + final String? title; + + /// The leading icon of the container. + final Widget? leading; + + /// The trailing icon of the container. + final Widget? trailing; + + /// The body of the container. + final Widget child; + + /// The width of the container. + final double? width; + + /// The height of the container. + final double? height; + + /// If true, the container is a window. + late final bool window; + + /// Spacing between the title and the body. + final bool spacing; + + /// The font size of the title. + static const titleFontSize = 19.0; + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + duration: Duration(milliseconds: 100), + padding: window + ? EdgeInsets.only(bottom: Spacing.smallSpacing) + : const EdgeInsets.all(Spacing.smallSpacing), + decoration: BoxDecoration( + color: context.theme.colorScheme.primary, + boxShadow: kElevationToShadow[6], + borderRadius: BorderRadius.circular(5), + ), + width: width, + height: height, + child: ConditionalWrapper( + condition: title != null || leading != null || trailing != null, + wrapper: (context, child) => Column( + children: [ + ConditionalWrapper( + condition: window, + wrapper: (context, child) => AnimatedContainer( + duration: Duration(milliseconds: 100), + padding: EdgeInsets.only( + left: Spacing.smallSpacing, right: Spacing.smallSpacing), + decoration: BoxDecoration( + color: context.theme.colorScheme.secondary, + borderRadius: BorderRadius.vertical(top: Radius.circular(5)), + ), + child: child, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + if (leading != null) leading!, + if (leading != null) Spacing.small(), + if (title != null) + Text( + title!, + style: TextStyle( + fontWeight: FontWeight.w600, + overflow: TextOverflow.ellipsis, + fontSize: titleFontSize), + textAlign: TextAlign.left, + ), + ], + ), + if (trailing != null) trailing!, + ], + ), + ), + if (spacing) Spacing.small(), + Expanded(child: child), + ], + ), + child: child, + ), + ); + } +} diff --git a/lib/shared/presentation/widgets/dialog.dart b/lib/shared/presentation/widgets/dialog.dart new file mode 100644 index 00000000..980c075b --- /dev/null +++ b/lib/shared/presentation/widgets/dialog.dart @@ -0,0 +1,359 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:lb_planner/shared/shared.dart'; + +/// Shows custom dialogs +class Dialog extends StatefulWidget { + /// Creates the structure of the confirm dialog + Dialog.confirm({ + Key? key, + required this.header, + required this.body, + this.confirmText, + this.cancelText, + required this.onConfirm, + this.onCancel, + required this.removeFromWidgetTree, + this.confirmIsBad = true, + required this.scrollable, + }) : super(key: key) { + confirmOnly = false; + } + + /// Creates the structure of the alert dialog + Dialog.alert({ + Key? key, + required this.header, + required this.body, + this.onConfirm, + this.confirmText, + required this.removeFromWidgetTree, + required this.scrollable, + }) : super(key: key) { + confirmIsBad = false; + confirmOnly = true; + } + + /// The header of the dialog. + final Widget? header; + + /// The body of the dialog. + final Widget body; + + /// Whether the confirm button should have errorColor as it's background color. + late final bool confirmIsBad; + + /// The text of the confirm button. + final String? confirmText; + + /// The text of the cancel button. + late final String? cancelText; + + /// The boolean that determines if the dialog has just one button. + late final bool confirmOnly; + + /// Whether the dialog body should be scrollable. + final bool scrollable; + + /// The callback that is called when the user confirms the dialog. + final Function()? onConfirm; + + /// Called when the dialog has to be removed from the widget tree. + final VoidCallback removeFromWidgetTree; + + /// The callback that is called when the user cancels the dialog. + late final Function()? onCancel; + + /// The padding between the elements in the dialog. + static const double padding = 20; + + /// The width factor of the body in the dialog. + static const double widthFactor = .5; + + /// The height factor of the body in the dialog. + static const double heightFactor = .8; + + /// The font size of the buttons in the dialog. + static const double btnFontSize = 16; + + /// The padding of the buttons in the dialog. + static const double btnPadding = 14; + + /// The font size of the title. + static const double titleFontSize = 30; + + @override + State createState() => _DialogState(); +} + +class _DialogState extends State with TickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + _controller = + AnimationController(vsync: this, duration: Duration(milliseconds: 300)); + _controller.forward(); + super.initState(); + focusNode.addListener(_ensureFocus); + + focusNode.requestFocus(); + } + + /// Removes the widget from the widget tree. + Future close() async { + await _controller.reverse(); + widget.removeFromWidgetTree(); + } + + ///Ensures focus if the focusNode doesn't have focus. + void _ensureFocus() async { + if (focusNode.hasFocus) return; + + await Future.delayed(Duration(milliseconds: 300)); + + if (!mounted) return; + + focusNode.requestFocus(); + } + + final focusNode = FocusNode(skipTraversal: true); + + @override + Widget build(BuildContext context) { + return RawKeyboardListener( + autofocus: true, + focusNode: focusNode, + onKey: (event) { + if (event.logicalKey == LogicalKeyboardKey.escape && + focusNode.hasPrimaryFocus) { + close(); + } + }, + child: FadeTransition( + opacity: Tween(begin: 0.4, end: 1).animate( + CurvedAnimation( + parent: _controller, + curve: Interval(0.0, 0.5), + ), + ), + child: ScaleTransition( + child: AlertDialog( + title: widget.header, + titlePadding: EdgeInsets.all(Dialog.padding), + buttonPadding: + EdgeInsets.only(left: Dialog.padding, right: Dialog.padding), + contentPadding: EdgeInsets.only( + bottom: Dialog.padding, + left: Dialog.padding, + right: Dialog.padding), + content: ConstrainedBox( + constraints: BoxConstraints( + minWidth: + MediaQuery.of(context).size.width * Dialog.widthFactor, + maxHeight: + MediaQuery.of(context).size.height * Dialog.heightFactor, + maxWidth: + MediaQuery.of(context).size.width * Dialog.widthFactor, + ), + child: AnimatedSize( + duration: Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + child: ConditionalWrapper( + condition: widget.scrollable, + wrapper: (context, child) => SingleChildScrollView( + controller: ScrollController(), + child: GestureDetector( + onTap: () => + FocusManager.instance.primaryFocus?.unfocus(), + child: child, + ), + ), + child: widget.body, + ), + ), + ), + actions: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (!widget.confirmOnly) + ElevatedButton( + child: Text(widget.cancelText ?? t.dialog_cancel), + style: ElevatedButton.styleFrom( + backgroundColor: widget.confirmIsBad + ? context.theme.colorScheme.primary + : context.theme.colorScheme.error, + textStyle: TextStyle(fontSize: Dialog.btnFontSize), + padding: EdgeInsets.all(Dialog.btnPadding), + ), + onPressed: () async { + await close(); + widget.onCancel?.call(); + }, + ), + Spacing.medium(), + ElevatedButton( + child: Text(widget.confirmText ?? + (widget.confirmOnly + ? t.alertDialog_confirm + : t.dialog_confirm)), + style: ElevatedButton.styleFrom( + backgroundColor: widget.confirmIsBad + ? context.theme.colorScheme.error + : context.theme.colorScheme.primary, + textStyle: TextStyle(fontSize: Dialog.btnFontSize), + padding: EdgeInsets.all(Dialog.btnPadding), + ), + onPressed: () async { + await close(); + widget.onConfirm?.call(); + }, + ), + ], + ) + ], + backgroundColor: context.theme.colorScheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(5)), + ), + ), + // ignore: no-magic-number + scale: Tween(begin: 1, end: 0.85).animate( + CurvedAnimation( + parent: _controller, + curve: Interval(0.0, 0.5, curve: Curves.easeOut), + ), + ), + ), + ), + ); + } +} + +/// Shows an confirm dialog +void showConfirmDialog( + BuildContext context, { + String? title, + Widget? header, + Widget? body, + String? confirmText, + String? cancelText, + Function()? onConfirm, + Function()? onCancel, + String? message, + bool confirmIsBad = true, + bool scrollable = true, +}) { + assert(body != null || message != null, + 'Either body or message must be provided.'); + assert(header != null || title != null, + 'Either header or title must be provided.'); + + var key = GlobalKey<_DialogState>(); + + OverlayEntry? dialogOverLay; + OverlayEntry background = + _generateBackground(() => key.currentState!.close()); + + var dialog = Dialog.confirm( + key: key, + header: header ?? + Text( + title!, + style: TextStyle(fontSize: Dialog.titleFontSize), + ), + body: ConditionalWidget( + condition: body != null, + ifTrue: body!, + ifFalse: Text(message!, + style: TextStyle( + overflow: TextOverflow.visible, + fontSize: 12, + letterSpacing: 0.4)), + ), + confirmText: confirmText, + cancelText: cancelText, + onConfirm: onConfirm, + onCancel: onCancel, + confirmIsBad: confirmIsBad, + scrollable: scrollable, + removeFromWidgetTree: () { + dialogOverLay!.remove(); + background.remove(); + }, + ); + + dialogOverLay = OverlayEntry( + builder: (context) => dialog, + ); + + Overlay.of(context).insert(background); + Overlay.of(context).insert(dialogOverLay); +} + +/// Shows an alert dialog +void showAlertDialog( + BuildContext context, { + String? title, + Widget? header, + Widget? body, + String? message, + String? confirmText, + Function()? onConfirm, + bool scrollable = true, +}) { + assert(body != null || message != null, + 'Either body or message must be provided.'); + assert(header != null || title != null, + 'Either header or title must be provided.'); + var key = GlobalKey<_DialogState>(); + + OverlayEntry? dialogOverLay; + OverlayEntry background = + _generateBackground(() => key.currentState!.close()); + + var dialog = Dialog.alert( + key: key, + header: header ?? + Text( + title!, + style: TextStyle(fontSize: Dialog.titleFontSize), + ), + body: ConditionalWidget( + condition: body != null, + ifTrue: body!, + ifFalse: Text(message!, + style: TextStyle( + overflow: TextOverflow.visible, + fontSize: 12, + letterSpacing: 0.4)), + ), + onConfirm: onConfirm, + confirmText: confirmText, + scrollable: scrollable, + removeFromWidgetTree: () { + dialogOverLay!.remove(); + background.remove(); + }, + ); + + dialogOverLay = OverlayEntry( + builder: (context) => dialog, + ); + + Overlay.of(context).insert(background); + Overlay.of(context).insert(dialogOverLay); +} + +/// generates a background overlay for dismissing the dialog +OverlayEntry _generateBackground(Function() dismiss) { + return OverlayEntry( + builder: (context) => GestureDetector( + onTap: dismiss, + child: Container( + color: Colors.black38, + ), + ), + ); +} diff --git a/lib/shared/presentation/widgets/screen_title_bar.dart b/lib/shared/presentation/widgets/screen_title_bar.dart index a853510d..041c302b 100644 --- a/lib/shared/presentation/widgets/screen_title_bar.dart +++ b/lib/shared/presentation/widgets/screen_title_bar.dart @@ -71,7 +71,7 @@ class ScreenTitleBar extends ConsumerWidget { width: profileImageSize, height: profileImageSize, child: CircleAvatar( - // TODO: backgroundImage: fallback image + child: Icon(Icons.account_circle_rounded), foregroundImage: NetworkImage(user.profileImageUrl), ), ), diff --git a/lib/shared/presentation/widgets/shimmer.dart b/lib/shared/presentation/widgets/shimmer.dart new file mode 100644 index 00000000..6e5a050a --- /dev/null +++ b/lib/shared/presentation/widgets/shimmer.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:lb_planner/shared/shared.dart'; +import 'package:shimmer/shimmer.dart'; + +class ShimmerEffect extends StatelessWidget { + // ignore: no-magic-number + const ShimmerEffect({Key? key, this.width, this.height = 30, this.child}) + : super(key: key); + + /// The width of the shimmer. + final double? width; + + /// The height of the shimmer. + final double? height; + + /// The child of the shimmer. + /// + /// If not set the shimmer will be a rounded container. + final Widget? child; + + /// Default value for [Shimmer.period] + static const period = Duration(milliseconds: 2000); + + @override + Widget build(BuildContext context) { + return Shimmer.fromColors( + period: period, + child: child ?? + Container( + height: height, + width: width, + decoration: BoxDecoration( + color: context.theme.colorScheme.secondary, + borderRadius: BorderRadius.circular(5), + ), + ), + baseColor: context.theme.colorScheme.secondary, + // ignore: no-magic-number + highlightColor: context.theme.colorScheme.secondary.lighten(0.02), + ); + } +} diff --git a/lib/shared/presentation/widgets/spacing.dart b/lib/shared/presentation/widgets/spacing.dart index b3d86368..2625ef36 100644 --- a/lib/shared/presentation/widgets/spacing.dart +++ b/lib/shared/presentation/widgets/spacing.dart @@ -1,4 +1,4 @@ -import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; /// Allows you to add spacing or padding in both the vertical and horizontal directions, /// or separately in either the vertical or horizontal direction. diff --git a/lib/shared/presentation/widgets/user_profile_img.dart b/lib/shared/presentation/widgets/user_profile_img.dart new file mode 100644 index 00000000..687fd840 --- /dev/null +++ b/lib/shared/presentation/widgets/user_profile_img.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lb_planner/features/auth/auth.dart'; +import 'package:lb_planner/shared/presentation/presentation.dart'; +import 'package:cached_network_image/cached_network_image.dart'; + +/// Displays the profile image of the current user with the current size. +class UserProfileImg extends StatelessWidget { + /// Displays the profile image of the current user with the current size. + const UserProfileImg({Key? key, required this.size, this.userId}) + : super(key: key); + + /// The size of the profile image. + final double size; + + /// The id of the user to display. If not specified, the current user is used. + final int? userId; + + /// Cache manager for the profile image. + static CacheManager get cacheManager => CacheManager( + Config( + 'user_profile', + + // ignore: no-magic-number + stalePeriod: Duration(days: 7), + ), + ); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, ref, _) { + var user = userId != null + ? ref.watch(usersProvider)[userId!] + : ref.watch(userProvider); + + return ClipOval( + child: ConditionalWidget( + condition: user != null && user.profileImageUrl.isNotEmpty, + ifTrue: CachedNetworkImage( + imageUrl: user!.profileImageUrl, + width: size, + height: size, + fit: BoxFit.contain, + placeholder: (_, __) => CircularProgressIndicator(), + errorWidget: (_, __, ___) => Icon( + Icons.account_circle, + size: size, + ), + cacheManager: cacheManager, + ), + ifFalse: ShimmerEffect(height: size, width: size), + ), + ); + }, + ); + } +} diff --git a/lib/shared/presentation/widgets/widgets.dart b/lib/shared/presentation/widgets/widgets.dart index 6e24c528..8955e79f 100644 --- a/lib/shared/presentation/widgets/widgets.dart +++ b/lib/shared/presentation/widgets/widgets.dart @@ -10,3 +10,9 @@ export 'scale_on_hover.dart'; export 'offset_on_hover.dart'; export 'hover_builder.dart'; export 'hoverable_widget.dart'; +export 'shimmer.dart'; +export 'color_utils.dart'; +export 'user_profile_img.dart'; +export 'conditional_widget.dart'; +export 'container.dart'; +export 'dialog.dart'; diff --git a/pubspec.lock b/pubspec.lock index 682047bb..09e1136d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -129,6 +129,30 @@ packages: url: "https://pub.dev" source: hosted version: "8.6.3" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: f98972704692ba679db144261172a8e20feb145636c617af0eb4022132a6797f + url: "https://pub.dev" + source: hosted + version: "3.3.0" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "56aa42a7a01e3c9db8456d9f3f999931f1e05535b5a424271e9a38cabf066613" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "759b9a9f8f6ccbb66c185df805fac107f05730b1dab9c64626d1008cca532257" + url: "https://pub.dev" + source: hosted + version: "1.1.0" catcher: dependency: "direct overridden" description: @@ -242,6 +266,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + enum_to_string: + dependency: "direct main" + description: + name: enum_to_string + sha256: bd9e83a33b754cb43a75b36a9af2a0b92a757bfd9847d2621ca0b1bed45f8e7a + url: "https://pub.dev" + source: hosted + version: "2.0.1" fake_async: dependency: transitive description: @@ -279,6 +311,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_cache_manager: + dependency: "direct main" + description: + name: flutter_cache_manager + sha256: "8207f27539deb83732fdda03e259349046a39a4c767269285f449ade355d54ba" + url: "https://pub.dev" + source: hosted + version: "3.3.1" flutter_lints: dependency: "direct dev" description: @@ -526,6 +566,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "45b40f99622f11901238e18d48f5f12ea36426d8eced9f4cbf58479c7aa2430d" + url: "https://pub.dev" + source: hosted + version: "2.0.0" package_config: dependency: transitive description: @@ -670,6 +718,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" + url: "https://pub.dev" + source: hosted + version: "0.27.7" sentry: dependency: transitive description: @@ -694,6 +750,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + shimmer: + dependency: "direct main" + description: + name: shimmer + sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter @@ -723,6 +787,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.0" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "591f1602816e9c31377d5f008c2d9ef7b8aca8941c3f89cc5fd9d84da0c38a9a" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: bb4738f15b23352822f4c42a531677e5c6f522e079461fd240ead29d8d8a54a6 + url: "https://pub.dev" + source: hosted + version: "2.5.0+2" stack_trace: dependency: transitive description: @@ -763,6 +843,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "5fcbd27688af6082f5abd611af56ee575342c30e87541d0245f7ff99faa02c60" + url: "https://pub.dev" + source: hosted + version: "3.1.0" term_glyph: dependency: transitive description: @@ -779,6 +867,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + timeago: + dependency: "direct main" + description: + name: timeago + sha256: c44b80cbc6b44627c00d76960f2af571f6f50e5dbedef4d9215d455e4335165b + url: "https://pub.dev" + source: hosted + version: "3.6.0" timing: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ab8287f6..4eb9c68f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,12 @@ dependencies: path_provider: ^2.1.1 riverpod: ^2.4.0 url_launcher: ^6.1.2 + shimmer: ^3.0.0 + flutter_cache_manager: ^3.3.1 + cached_network_image: ^3.3.0 + timeago: ^3.6.0 + enum_to_string: ^2.0.1 + dev_dependencies: auto_route_generator: ^7.3.2 @@ -50,6 +56,7 @@ dev_dependencies: freezed: ^2.2.0 json_serializable: ^6.7.1 + dependency_overrides: catcher: git: