diff --git a/lib/cubits/cubits.dart b/lib/cubits/cubits.dart index b0d0c7f5..c4183ca0 100644 --- a/lib/cubits/cubits.dart +++ b/lib/cubits/cubits.dart @@ -12,6 +12,7 @@ export 'post/post_cubit.dart'; export 'preference/preference_cubit.dart'; export 'reminder/reminder_cubit.dart'; export 'remote_config/remote_config_cubit.dart'; +export 'review_request/review_request_cubit.dart'; export 'search/search_cubit.dart'; export 'split_view/split_view_cubit.dart'; export 'submit/submit_cubit.dart'; diff --git a/lib/cubits/review_request/review_request_cubit.dart b/lib/cubits/review_request/review_request_cubit.dart new file mode 100644 index 00000000..68aa2853 --- /dev/null +++ b/lib/cubits/review_request/review_request_cubit.dart @@ -0,0 +1,42 @@ +import 'package:equatable/equatable.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; + +part 'review_request_state.dart'; + +class ReviewRequestCubit extends HydratedCubit { + ReviewRequestCubit() : super(ReviewRequestState.init()) { + _incrementLaunchCounter(); + } + + void _incrementLaunchCounter() => + emit(state.copyWith(launchCounter: state.launchCounter + 1)); + + static const int _askWhen = 100; + static const String _launchCounterKey = 'launchCounterKey'; + static const String _firstRecordedLaunchDateKey = + 'firstRecordedLaunchDateKey'; + + bool get feelingLucky => _askWhen == state.launchCounter; + + void markAsShown() => emit(state.copyWith(hasShown: true)); + + @override + ReviewRequestState? fromJson(Map json) { + return state.copyWith( + launchCounter: json[_launchCounterKey] as int? ?? 0, + firstRecordedLaunchDate: DateTime.fromMillisecondsSinceEpoch( + json[_firstRecordedLaunchDateKey] as int? ?? + DateTime.now().millisecondsSinceEpoch, + ), + ); + } + + @override + Map? toJson(ReviewRequestState state) { + return { + _launchCounterKey: state.launchCounter, + _firstRecordedLaunchDateKey: + state.firstRecordedLaunchDate.millisecondsSinceEpoch, + }; + } +} diff --git a/lib/cubits/review_request/review_request_state.dart b/lib/cubits/review_request/review_request_state.dart new file mode 100644 index 00000000..ff870860 --- /dev/null +++ b/lib/cubits/review_request/review_request_state.dart @@ -0,0 +1,38 @@ +part of 'review_request_cubit.dart'; + +class ReviewRequestState extends Equatable { + const ReviewRequestState({ + required this.launchCounter, + required this.firstRecordedLaunchDate, + required this.hasShown, + }); + + ReviewRequestState.init() + : launchCounter = 0, + firstRecordedLaunchDate = .now(), + hasShown = false; + + final int launchCounter; + final DateTime firstRecordedLaunchDate; + final bool hasShown; + + ReviewRequestState copyWith({ + int? launchCounter, + DateTime? firstRecordedLaunchDate, + bool? hasShown, + }) { + return ReviewRequestState( + launchCounter: launchCounter ?? this.launchCounter, + firstRecordedLaunchDate: + firstRecordedLaunchDate ?? this.firstRecordedLaunchDate, + hasShown: hasShown ?? this.hasShown, + ); + } + + @override + List get props => [ + launchCounter, + firstRecordedLaunchDate, + hasShown, + ]; +} diff --git a/lib/main.dart b/lib/main.dart index 9a9159e4..1770133b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -227,6 +227,10 @@ class HackiApp extends StatelessWidget { preferenceCubit: context.read(), ), ), + BlocProvider( + lazy: false, + create: (BuildContext context) => ReviewRequestCubit(), + ), ], child: BlocConsumer( listenWhen: (PreferenceState previous, PreferenceState current) => diff --git a/lib/screens/home/home_screen.dart b/lib/screens/home/home_screen.dart index 831c26b9..90caf319 100644 --- a/lib/screens/home/home_screen.dart +++ b/lib/screens/home/home_screen.dart @@ -95,6 +95,22 @@ class _HomeScreenState extends State }); tabController = TabController(length: tabLength, vsync: this); + + WidgetsBinding.instance.addPostFrameCallback((_) { + final ReviewRequestCubit reviewRequestCubit = context + .read(); + if (!reviewRequestCubit.state.hasShown && + reviewRequestCubit.feelingLucky) { + reviewRequestCubit.markAsShown(); + showModalBottomSheet( + context: context, + isDismissible: false, + builder: (BuildContext context) { + return const ReviewRequestBottomSheet(); + }, + ); + } + }); } @override diff --git a/lib/screens/home/widgets/review_request_bottom_sheet.dart b/lib/screens/home/widgets/review_request_bottom_sheet.dart new file mode 100644 index 00000000..75493b26 --- /dev/null +++ b/lib/screens/home/widgets/review_request_bottom_sheet.dart @@ -0,0 +1,220 @@ +import 'dart:io'; + +import 'package:go_router/go_router.dart'; +import 'package:hacki/config/constants.dart'; +import 'package:hacki/styles/styles.dart'; +import 'package:hacki/utils/utils.dart'; +import 'package:material_ui/material_ui.dart'; + +class ReviewRequestBottomSheet extends StatefulWidget { + const ReviewRequestBottomSheet({super.key}); + + @override + State createState() => + _ReviewRequestBottomSheetState(); +} + +enum Stage { stars, reviewRequest, issueSubmission } + +class _ReviewRequestBottomSheetState extends State { + final PageController pageController = PageController(); + Stage currentStage = .stars; + int stars = 1; + static const int maxStars = 5; + + @override + Widget build(BuildContext context) { + final Color highlightColor = Theme.of(context).colorScheme.primary; + return Container( + height: 360, + color: Theme.of(context).scaffoldBackgroundColor, + child: PageView( + controller: pageController, + physics: const NeverScrollableScrollPhysics(), + children: [ + Column( + children: [ + SizedBoxes.pt24, + const Padding( + padding: EdgeInsets.symmetric( + vertical: Dimens.pt16, + horizontal: Dimens.pt12, + ), + child: Text( + 'How would you rate Hacki so far?', + style: TextStyle(fontSize: TextDimens.pt20), + textAlign: .center, + ), + ), + SizedBoxes.pt24, + Row( + mainAxisAlignment: .center, + children: [ + for (final int i in List.generate( + maxStars, + (int i) => i + 1, + )) ...[ + InkWell( + onTap: () { + HapticFeedbackUtils.selection(); + setState(() { + stars = i; + }); + }, + customBorder: StarBorder( + side: BorderSide( + color: highlightColor, + width: Dimens.pt2, + ), + ), + child: Container( + height: Dimens.pt50, + width: Dimens.pt50, + decoration: ShapeDecoration( + color: i <= stars + ? highlightColor + : Palette.transparent, + shape: StarBorder( + side: i <= stars + ? .none + : BorderSide( + color: highlightColor, + width: Dimens.pt2, + ), + ), + ), + ), + ), + SizedBoxes.pt12, + ], + ], + ), + const Spacer(), + ElevatedButton.icon( + onPressed: () { + HapticFeedbackUtils.selection(); + if (stars == maxStars) { + pageController.animateToPage( + Stage.reviewRequest.index, + duration: AppDurations.ms300, + curve: Curves.bounceInOut, + ); + } else { + pageController.animateToPage( + Stage.issueSubmission.index, + duration: AppDurations.ms300, + curve: Curves.bounceInOut, + ); + } + }, + label: const Icon(Icons.check, size: TextDimens.pt64), + ), + SizedBoxes.pt36, + ], + ), + Column( + children: [ + SizedBoxes.pt24, + const Padding( + padding: EdgeInsets.symmetric(horizontal: Dimens.pt12), + child: Text( + 'Would you like to leave a review?', + style: TextStyle(fontSize: TextDimens.pt20), + textAlign: .center, + ), + ), + SizedBoxes.pt12, + const Padding( + padding: EdgeInsets.symmetric(horizontal: Dimens.pt12), + child: Text( + ''' +(๑•̀ㅂ•́)و✧ + +Hacki is completely free and open source. Your feedback keeps me motivated to make it even better. : )''', + style: TextStyle(fontSize: TextDimens.pt16), + textAlign: .center, + ), + ), + const Spacer(), + Row( + mainAxisAlignment: .center, + children: [ + ElevatedButton.icon( + onPressed: () { + HapticFeedbackUtils.selection(); + context.pop(); + }, + label: const Icon(Icons.close, size: TextDimens.pt64), + ), + SizedBoxes.pt48, + ElevatedButton.icon( + onPressed: () { + HapticFeedbackUtils.selection(); + LinkUtils.launch( + Platform.isIOS + ? Constants.appStoreLink + : Constants.googlePlayLink, + context, + ); + context.pop(); + }, + label: const Icon(Icons.check, size: TextDimens.pt64), + ), + ], + ), + SizedBoxes.pt48, + ], + ), + Column( + children: [ + SizedBoxes.pt24, + const Padding( + padding: EdgeInsets.symmetric(horizontal: Dimens.pt12), + child: Text( + 'Would you like to share some feedback?', + style: TextStyle(fontSize: TextDimens.pt20), + textAlign: .center, + ), + ), + SizedBoxes.pt12, + const Padding( + padding: EdgeInsets.symmetric(horizontal: Dimens.pt12), + child: Text( + ''' +Hacki is completely free and open source. Your feedback keeps me motivated to make it even better. + +Tapping YES will take you to Hacki's GitHub page.''', + style: TextStyle(fontSize: TextDimens.pt16), + textAlign: .center, + ), + ), + const Spacer(), + Row( + mainAxisAlignment: .center, + children: [ + ElevatedButton.icon( + onPressed: () { + HapticFeedbackUtils.selection(); + context.pop(); + }, + label: const Icon(Icons.close, size: TextDimens.pt64), + ), + SizedBoxes.pt48, + ElevatedButton.icon( + onPressed: () { + HapticFeedbackUtils.selection(); + LinkUtils.launch(Constants.githubLink, context); + context.pop(); + }, + label: const Icon(Icons.check, size: TextDimens.pt64), + ), + ], + ), + SizedBoxes.pt48, + ], + ), + ], + ), + ); + } +} diff --git a/lib/screens/home/widgets/widgets.dart b/lib/screens/home/widgets/widgets.dart index f1b8df03..1874365c 100644 --- a/lib/screens/home/widgets/widgets.dart +++ b/lib/screens/home/widgets/widgets.dart @@ -1,3 +1,4 @@ export 'mobile_home_screen.dart'; export 'pinned_stories.dart'; +export 'review_request_bottom_sheet.dart'; export 'tablet_home_screen.dart'; diff --git a/lib/styles/dimens.dart b/lib/styles/dimens.dart index fd832fd5..02958dc9 100644 --- a/lib/styles/dimens.dart +++ b/lib/styles/dimens.dart @@ -47,6 +47,8 @@ abstract class TextDimens { static const double pt24 = 24; static const double pt26 = 26; static const double pt36 = 36; + static const double pt48 = 48; + static const double pt64 = 64; } abstract class NumSwitch {