From 528de789826e8ed51cd737bcb22df5d83307f127 Mon Sep 17 00:00:00 2001 From: Zander Kotze Date: Mon, 15 Jun 2026 15:55:35 +0200 Subject: [PATCH 1/5] feat(models): add AppTip enum for contextual home tips Co-authored-by: Cursor --- packages/models/lib/models.dart | 1 - .../lib/src/enums/app_tips/app_tip.dart | 40 +++++++++++++++++++ packages/models/lib/src/enums/export.dart | 2 +- .../enums/product_tour/product_tour_step.dart | 34 ---------------- .../lib/src/enums/storage/storage_keys.dart | 1 + .../models/analytics/tutorial_event_data.dart | 8 ++-- .../lib/src/product_tour/showcase_data.dart | 33 --------------- 7 files changed, 46 insertions(+), 73 deletions(-) create mode 100644 packages/models/lib/src/enums/app_tips/app_tip.dart delete mode 100644 packages/models/lib/src/enums/product_tour/product_tour_step.dart delete mode 100644 packages/models/lib/src/product_tour/showcase_data.dart diff --git a/packages/models/lib/models.dart b/packages/models/lib/models.dart index b961b2a1..a50e9bd2 100644 --- a/packages/models/lib/models.dart +++ b/packages/models/lib/models.dart @@ -7,4 +7,3 @@ export 'src/dto/export_dto.dart'; export 'src/enums/export.dart'; export 'src/mappers/export_mappers.dart'; export 'src/models/export.dart'; -export 'src/product_tour/showcase_data.dart'; diff --git a/packages/models/lib/src/enums/app_tips/app_tip.dart b/packages/models/lib/src/enums/app_tips/app_tip.dart new file mode 100644 index 00000000..6340cd50 --- /dev/null +++ b/packages/models/lib/src/enums/app_tips/app_tip.dart @@ -0,0 +1,40 @@ +/// Contextual, non-blocking tips shown on the home screen. +enum AppTip { + collections(0), + addCollection(1), + addEntry(2), + entryActions(3), + editAndSearch(4), + drawer(5), + ; + + const AppTip(this.bitIndex); + + final int bitIndex; + + int get mask => 1 << bitIndex; + + static const List orderedTips = [ + collections, + addCollection, + addEntry, + entryActions, + editAndSearch, + drawer, + ]; + + static AppTip? firstUndismissed(int dismissedMask) { + for (final tip in orderedTips) { + if ((dismissedMask & tip.mask) == 0) { + return tip; + } + } + + return null; + } + + static int allDismissedMask = orderedTips.fold( + 0, + (mask, tip) => mask | tip.mask, + ); +} diff --git a/packages/models/lib/src/enums/export.dart b/packages/models/lib/src/enums/export.dart index da8a6125..072a72ff 100644 --- a/packages/models/lib/src/enums/export.dart +++ b/packages/models/lib/src/enums/export.dart @@ -1,6 +1,6 @@ export 'analytics/export.dart'; +export 'app_tips/app_tip.dart'; export 'feedback/feedback_field.dart'; export 'firebase/firebase_config_keys.dart'; export 'menu/menu_items.dart'; -export 'product_tour/product_tour_step.dart'; export 'storage/storage_keys.dart'; diff --git a/packages/models/lib/src/enums/product_tour/product_tour_step.dart b/packages/models/lib/src/enums/product_tour/product_tour_step.dart deleted file mode 100644 index 8a4715f3..00000000 --- a/packages/models/lib/src/enums/product_tour/product_tour_step.dart +++ /dev/null @@ -1,34 +0,0 @@ -enum ProductTourStep { - welcomePopup(0), - showCollection(1), - showItemsInCollection(2), - addNewCollection(3), - addNewItem(4), - showItemActions(5), - showCollectionActions(6), - showCollectionMenu(7), - showEditAndSearch(8), - showSettings(9), - showAppearanceSection(10), - showDataSection(11), - showMoreSection(12), - closeSettings(13), - thanksPopup(14), - noneCompleted(-1), - reset(-2), - ; - - const ProductTourStep(this.value); - - final int value; - - static ProductTourStep? fromValue(int value) { - for (final step in ProductTourStep.values) { - if (step.value == value) { - return step; - } - } - - return null; - } -} diff --git a/packages/models/lib/src/enums/storage/storage_keys.dart b/packages/models/lib/src/enums/storage/storage_keys.dart index a78bb7b7..f68172a3 100644 --- a/packages/models/lib/src/enums/storage/storage_keys.dart +++ b/packages/models/lib/src/enums/storage/storage_keys.dart @@ -9,6 +9,7 @@ enum StorageKeys { analyticsUserId('_analyticsUserId'), isImportDataBannerDismissed('_isImportDataBannerDismissed'), isSignupBannerDismissed('_isSignupBannerDismissed'), + dismissedAppTipsMask('_dismissedAppTipsMask'), lastUsedEmail('_lastUsedEmail'), feedbackSubmissionDay('_feedbackSubmissionDay'), feedbackSubmissionCount('_feedbackSubmissionCount'), diff --git a/packages/models/lib/src/models/analytics/tutorial_event_data.dart b/packages/models/lib/src/models/analytics/tutorial_event_data.dart index 0ad68630..4b5a2de2 100644 --- a/packages/models/lib/src/models/analytics/tutorial_event_data.dart +++ b/packages/models/lib/src/models/analytics/tutorial_event_data.dart @@ -1,17 +1,17 @@ import 'package:models/src/enums/analytics/export.dart'; -import 'package:models/src/enums/product_tour/product_tour_step.dart'; +import 'package:models/src/enums/app_tips/app_tip.dart'; import 'package:models/src/models/analytics/analytics_event_data.dart'; class TutorialEventData extends AnalyticsEventData { const TutorialEventData({ required this.page, required this.action, - this.step, + this.tip, }); final AnalyticsPage page; final AnalyticsAction action; - final ProductTourStep? step; + final AppTip? tip; @override AnalyticsEventName get eventName => AnalyticsEventName.tutorialAction; @@ -20,6 +20,6 @@ class TutorialEventData extends AnalyticsEventData { Map get parameters => { AnalyticsParamKey.page: page.key, AnalyticsParamKey.action: action.key, - AnalyticsParamKey.tutorialStep: step?.value, + if (tip != null) AnalyticsParamKey.tutorialStep: tip!.bitIndex, }; } diff --git a/packages/models/lib/src/product_tour/showcase_data.dart b/packages/models/lib/src/product_tour/showcase_data.dart deleted file mode 100644 index 865b02f2..00000000 --- a/packages/models/lib/src/product_tour/showcase_data.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:flutter/material.dart'; - -class ShowcaseData { - const ShowcaseData({ - required this.description, - this.title, - this.onTargetClick, - this.disposeOnTap = false, - this.disableBarrierInteraction = true, - this.onBarrierClick, - this.overlayOpacity = 0.5, - this.overlayColor = Colors.black54, - this.tooltipPosition, - this.tooltipPadding, - }); - - factory ShowcaseData.empty() => const ShowcaseData( - description: '', - ); - - final String description; - final String? title; - final VoidCallback? onTargetClick; - final bool? disposeOnTap; - final bool? disableBarrierInteraction; - final VoidCallback? onBarrierClick; - final double overlayOpacity; - final Color overlayColor; - final Position? tooltipPosition; - final EdgeInsets? tooltipPadding; -} - -enum Position { top, bottom } From a1b4ac6e4abba2c0bc01a000a4209fd08ca4a276 Mon Sep 17 00:00:00 2001 From: Zander Kotze Date: Mon, 15 Jun 2026 15:55:41 +0200 Subject: [PATCH 2/5] feat(core): replace linear product tour with AppTipsController Co-authored-by: Cursor --- .../src/application/product/product_bloc.dart | 95 +++------- .../application/product/product_event.dart | 33 ++-- .../application/product/product_state.dart | 11 +- packages/core/lib/src/controllers/export.dart | 2 +- .../implementations/app_tips_controller.dart | 64 +++++++ .../product_tour_controller.dart | 96 ---------- .../utils/app_tips_storage_codec.dart | 56 ++++++ .../product_tour_step_storage_codec.dart | 32 ---- .../interfaces/i_app_tips_controller.dart | 14 ++ .../interfaces/i_product_tour_controller.dart | 14 -- .../implementations/app_storage_service.dart | 14 ++ .../interfaces/i_app_storage_service.dart | 2 + packages/core/pubspec.yaml | 1 - packages/core/test/mocks.dart | 2 +- .../product/product_bloc_test.dart | 134 +++----------- .../controllers/app_tips_controller_test.dart | 81 +++++++++ .../product_tour_controller_test.dart | 168 ------------------ .../utils/app_tips_storage_codec_test.dart | 25 +++ 18 files changed, 319 insertions(+), 525 deletions(-) create mode 100644 packages/core/lib/src/controllers/implementations/app_tips_controller.dart delete mode 100644 packages/core/lib/src/controllers/implementations/product_tour_controller.dart create mode 100644 packages/core/lib/src/controllers/implementations/utils/app_tips_storage_codec.dart delete mode 100644 packages/core/lib/src/controllers/implementations/utils/product_tour_step_storage_codec.dart create mode 100644 packages/core/lib/src/controllers/interfaces/i_app_tips_controller.dart delete mode 100644 packages/core/lib/src/controllers/interfaces/i_product_tour_controller.dart create mode 100644 packages/core/test/src/controllers/app_tips_controller_test.dart delete mode 100644 packages/core/test/src/controllers/product_tour_controller_test.dart create mode 100644 packages/core/test/src/controllers/utils/app_tips_storage_codec_test.dart diff --git a/packages/core/lib/src/application/product/product_bloc.dart b/packages/core/lib/src/application/product/product_bloc.dart index 5803ba8b..d6ac4a4c 100644 --- a/packages/core/lib/src/application/product/product_bloc.dart +++ b/packages/core/lib/src/application/product/product_bloc.dart @@ -12,139 +12,90 @@ part 'product_bloc.g.dart'; @Singleton() class ProductBloc extends Bloc { ProductBloc( - this._productTourController, - this._tutorialRepository, + this._appTipsController, this._analyticsService, ) : super(ProductState.initial()) { on((event, emit) async { switch (event) { case OnInit(): - final currentStep = await _productTourController.currentStep; + final activeTip = await _appTipsController.activeTip; await _analyticsService.logEvent( TutorialEventData( - page: AnalyticsPage.tutorial, + page: AnalyticsPage.home, action: AnalyticsAction.open, - step: currentStep, + tip: activeTip, ), ); emit( state.copyWith( - currentStep: currentStep, + activeTip: activeTip, isLoading: false, errorMessage: null, ), ); break; - case OnNextStep(): - await _productTourController.nextStep(); - final currentStep = await _productTourController.currentStep; + case OnDismissTip(:final tip): + await _appTipsController.dismissTip(tip); + final activeTip = await _appTipsController.activeTip; await _analyticsService.logEvent( TutorialEventData( - page: AnalyticsPage.tutorial, + page: AnalyticsPage.home, action: AnalyticsAction.next, - step: currentStep, + tip: activeTip, ), ); emit( state.copyWith( - currentStep: currentStep, - isLoading: false, - errorMessage: null, - ), - ); - - break; - case OnPreviousStep(): - await _productTourController.previousStep(); - final currentStep = await _productTourController.currentStep; - await _analyticsService.logEvent( - TutorialEventData( - page: AnalyticsPage.tutorial, - action: AnalyticsAction.previous, - step: currentStep, - ), - ); - - emit( - state.copyWith( - currentStep: currentStep, + activeTip: activeTip, isLoading: false, errorMessage: null, ), ); break; - case OnSkipTour(): - await _productTourController.completeTour(); + case OnSkipAllTips(): + await _appTipsController.completeTips(); await _analyticsService.logEvent( const TutorialEventData( - page: AnalyticsPage.tutorial, + page: AnalyticsPage.home, action: AnalyticsAction.skip, - step: ProductTourStep.noneCompleted, ), ); emit( state.copyWith( - currentStep: ProductTourStep.noneCompleted, + activeTip: null, isLoading: false, errorMessage: null, ), ); break; - case OnResetTour(): + case OnResetTips(): emit(state.copyWith(isLoading: true)); - await _productTourController.resetTour(); + await _appTipsController.resetTips(); + final activeTip = await _appTipsController.activeTip; await _analyticsService.logEvent( - const TutorialEventData( - page: AnalyticsPage.tutorial, + TutorialEventData( + page: AnalyticsPage.home, action: AnalyticsAction.reset, - step: ProductTourStep.reset, + tip: activeTip, ), ); emit( state.copyWith( - currentStep: ProductTourStep.reset, + activeTip: activeTip, isLoading: false, errorMessage: null, ), ); - break; - case OnLoadData(): - final tabs = await _tutorialRepository.loadTutorialData(); - await _analyticsService.logEvent( - CrudEventData( - page: AnalyticsPage.tutorial, - entity: AnalyticsEntity.tab, - action: AnalyticsAction.open, - itemCount: tabs.length, - ), - ); - - emit( - state.copyWith( - tabs: tabs, - isLoading: false, - ), - ); - break; - case OnClearData(): - emit( - state.copyWith( - tabs: null, - isLoading: true, - ), - ); - break; } }); } - final IProductTourController _productTourController; - final ITutorialRepository _tutorialRepository; + final IAppTipsController _appTipsController; final IAnalyticsService _analyticsService; } diff --git a/packages/core/lib/src/application/product/product_event.dart b/packages/core/lib/src/application/product/product_event.dart index 91e1c70d..79e9c6b5 100644 --- a/packages/core/lib/src/application/product/product_event.dart +++ b/packages/core/lib/src/application/product/product_event.dart @@ -4,38 +4,25 @@ sealed class ProductEvent { const ProductEvent(); const factory ProductEvent.init() = OnInit; - const factory ProductEvent.nextStep() = OnNextStep; - const factory ProductEvent.previousStep() = OnPreviousStep; - const factory ProductEvent.skipTour() = OnSkipTour; - const factory ProductEvent.resetTour() = OnResetTour; - const factory ProductEvent.onLoadData() = OnLoadData; - const factory ProductEvent.onClearData() = OnClearData; + const factory ProductEvent.dismissTip(AppTip tip) = OnDismissTip; + const factory ProductEvent.skipAllTips() = OnSkipAllTips; + const factory ProductEvent.resetTips() = OnResetTips; } final class OnInit extends ProductEvent { const OnInit(); } -final class OnNextStep extends ProductEvent { - const OnNextStep(); -} - -final class OnPreviousStep extends ProductEvent { - const OnPreviousStep(); -} - -final class OnSkipTour extends ProductEvent { - const OnSkipTour(); -} +final class OnDismissTip extends ProductEvent { + const OnDismissTip(this.tip); -final class OnResetTour extends ProductEvent { - const OnResetTour(); + final AppTip tip; } -final class OnLoadData extends ProductEvent { - const OnLoadData(); +final class OnSkipAllTips extends ProductEvent { + const OnSkipAllTips(); } -final class OnClearData extends ProductEvent { - const OnClearData(); +final class OnResetTips extends ProductEvent { + const OnResetTips(); } diff --git a/packages/core/lib/src/application/product/product_state.dart b/packages/core/lib/src/application/product/product_state.dart index 696c3456..f4042a6c 100644 --- a/packages/core/lib/src/application/product/product_state.dart +++ b/packages/core/lib/src/application/product/product_state.dart @@ -3,24 +3,21 @@ part of 'product_bloc.dart'; @CopyWith() class ProductState extends Equatable { const ProductState({ - required this.currentStep, - required this.tabs, + required this.activeTip, required this.isLoading, required this.errorMessage, }); factory ProductState.initial() => const ProductState( - currentStep: ProductTourStep.noneCompleted, - tabs: null, + activeTip: null, isLoading: false, errorMessage: null, ); - final ProductTourStep currentStep; - final List? tabs; + final AppTip? activeTip; final bool isLoading; final String? errorMessage; @override - List get props => [currentStep, tabs, isLoading, errorMessage]; + List get props => [activeTip, isLoading, errorMessage]; } diff --git a/packages/core/lib/src/controllers/export.dart b/packages/core/lib/src/controllers/export.dart index 7e39734e..999a3f57 100644 --- a/packages/core/lib/src/controllers/export.dart +++ b/packages/core/lib/src/controllers/export.dart @@ -1 +1 @@ -export 'interfaces/i_product_tour_controller.dart'; +export 'interfaces/i_app_tips_controller.dart'; diff --git a/packages/core/lib/src/controllers/implementations/app_tips_controller.dart b/packages/core/lib/src/controllers/implementations/app_tips_controller.dart new file mode 100644 index 00000000..0cd3a758 --- /dev/null +++ b/packages/core/lib/src/controllers/implementations/app_tips_controller.dart @@ -0,0 +1,64 @@ +import 'package:core/core.dart'; +import 'package:injectable/injectable.dart'; +import 'package:models/models.dart'; + +import 'utils/app_tips_storage_codec.dart'; + +@Singleton(as: IAppTipsController) +class AppTipsController implements IAppTipsController { + AppTipsController( + this._appStorageService, + ); + + final IAppStorageService _appStorageService; + + @override + Future init() async {} + + @override + Future get activeTip async { + if (await _appStorageService.isCompleted) { + return null; + } + + final mask = await _resolvedDismissedMask(); + return AppTip.firstUndismissed(mask); + } + + @override + Future dismissTip(AppTip tip) async { + final mask = await _resolvedDismissedMask(); + final updatedMask = mask | tip.mask; + await _appStorageService.setDismissedAppTipsMask(updatedMask); + + if (AppTip.firstUndismissed(updatedMask) == null) { + await completeTips(); + } + } + + @override + Future completeTips() async { + await _appStorageService.setDismissedAppTipsMask(AppTip.allDismissedMask); + await _appStorageService.setIsCompleted(true); + } + + @override + Future resetTips() async { + await _appStorageService.resetTour(); + } + + Future _resolvedDismissedMask() async { + final storedMask = await _appStorageService.dismissedAppTipsMask; + if (storedMask != 0) { + return storedMask; + } + + final legacyStep = await _appStorageService.currentStep; + final migratedMask = AppTipsStorageCodec.migrateLegacyTourProgress(legacyStep); + if (migratedMask != 0) { + await _appStorageService.setDismissedAppTipsMask(migratedMask); + } + + return migratedMask; + } +} diff --git a/packages/core/lib/src/controllers/implementations/product_tour_controller.dart b/packages/core/lib/src/controllers/implementations/product_tour_controller.dart deleted file mode 100644 index 77f0bb86..00000000 --- a/packages/core/lib/src/controllers/implementations/product_tour_controller.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'package:core/core.dart'; -import 'package:flutter/material.dart'; -import 'package:injectable/injectable.dart'; -import 'package:models/models.dart'; - -import 'utils/product_tour_step_storage_codec.dart'; - -@Singleton(as: IProductTourController) -class ProductTourController implements IProductTourController { - ProductTourController( - this._appStorageService, - ); - - final IAppStorageService _appStorageService; - - @override - Future init() async {} - - @override - Future get currentStep async { - final storedStep = await _appStorageService.currentStep; - final isCompleted = await _appStorageService.isCompleted; - - if (isCompleted) { - return ProductTourStep.noneCompleted; - } - - if (storedStep < 0) { - return ProductTourStep.welcomePopup; - } - - final normalizedStepValue = - ProductTourStepStorageCodec.normalizeStoredStepToStableValue( - storedStep, - ); - - if (normalizedStepValue == null) { - return ProductTourStep.welcomePopup; - } - - final normalizedStoredStep = - ProductTourStepStorageCodec.encodeStableStepValue(normalizedStepValue); - - if (storedStep != normalizedStoredStep) { - await _appStorageService.setCurrentStep(normalizedStoredStep); - } - - return ProductTourStep.fromValue(normalizedStepValue) ?? - ProductTourStep.welcomePopup; - } - - @override - Future nextStep() async { - final current = await currentStep; - final nextStep = ProductTourStep.fromValue(current.value + 1); - - if (nextStep != null && nextStep.value >= 0) { - await _appStorageService.setCurrentStep( - ProductTourStepStorageCodec.encodeStableStepValue(nextStep.value), - ); - return; - } - - if (current == ProductTourStep.thanksPopup) { - await completeTour(); - return; - } - - throw Exception('No more steps available in the product tour.'); - } - - @override - Future previousStep({BuildContext? context}) async { - final current = await currentStep; - final previousStep = ProductTourStep.fromValue(current.value - 1); - - if (previousStep != null && previousStep.value >= 0) { - await _appStorageService.setCurrentStep( - ProductTourStepStorageCodec.encodeStableStepValue(previousStep.value), - ); - return; - } - - throw Exception('No previous step available in the product tour.'); - } - - @override - Future completeTour() async { - await _appStorageService.setIsCompleted(true); - } - - @override - Future resetTour() async { - await _appStorageService.resetTour(); - } -} diff --git a/packages/core/lib/src/controllers/implementations/utils/app_tips_storage_codec.dart b/packages/core/lib/src/controllers/implementations/utils/app_tips_storage_codec.dart new file mode 100644 index 00000000..d2096492 --- /dev/null +++ b/packages/core/lib/src/controllers/implementations/utils/app_tips_storage_codec.dart @@ -0,0 +1,56 @@ +import 'package:models/models.dart'; + +class AppTipsStorageCodec { + const AppTipsStorageCodec._(); + + static const int stableStepOffset = 1000; + + /// Maps legacy linear tour progress to dismissed tip bits. + static int migrateLegacyTourProgress(int storedStep) { + final stableValue = storedStep >= stableStepOffset + ? storedStep - stableStepOffset + : storedStep; + + if (stableValue < 0) { + return 0; + } + + if (stableValue >= 14) { + return AppTip.allDismissedMask; + } + + if (stableValue >= 8) { + return _maskThrough(AppTip.editAndSearch); + } + + if (stableValue >= 6) { + return _maskThrough(AppTip.entryActions); + } + + if (stableValue >= 4) { + return _maskThrough(AppTip.addEntry); + } + + if (stableValue >= 3) { + return _maskThrough(AppTip.addCollection); + } + + if (stableValue >= 1) { + return _maskThrough(AppTip.collections); + } + + return 0; + } + + static int _maskThrough(AppTip tip) { + var mask = 0; + for (final current in AppTip.orderedTips) { + mask |= current.mask; + if (current == tip) { + break; + } + } + + return mask; + } +} diff --git a/packages/core/lib/src/controllers/implementations/utils/product_tour_step_storage_codec.dart b/packages/core/lib/src/controllers/implementations/utils/product_tour_step_storage_codec.dart deleted file mode 100644 index be05ce78..00000000 --- a/packages/core/lib/src/controllers/implementations/utils/product_tour_step_storage_codec.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:models/models.dart'; - -class ProductTourStepStorageCodec { - const ProductTourStepStorageCodec._(); - - static const int stableStepOffset = 1000; - - static int? normalizeStoredStepToStableValue(int storedStep) { - if (storedStep >= stableStepOffset) { - final stableValue = storedStep - stableStepOffset; - final step = ProductTourStep.fromValue(stableValue); - return step != null && step.value >= 0 ? stableValue : null; - } - - final legacyStepValue = _migrateLegacyIndex(storedStep); - final step = ProductTourStep.fromValue(legacyStepValue); - return step != null && step.value >= 0 ? legacyStepValue : null; - } - - static int encodeStableStepValue(int stableValue) { - return stableValue + stableStepOffset; - } - - static int _migrateLegacyIndex(int legacyIndex) { - if (legacyIndex >= ProductTourStep.showEditAndSearch.value && - legacyIndex <= ProductTourStep.closeSettings.value) { - return legacyIndex + 1; - } - - return legacyIndex; - } -} diff --git a/packages/core/lib/src/controllers/interfaces/i_app_tips_controller.dart b/packages/core/lib/src/controllers/interfaces/i_app_tips_controller.dart new file mode 100644 index 00000000..180988bb --- /dev/null +++ b/packages/core/lib/src/controllers/interfaces/i_app_tips_controller.dart @@ -0,0 +1,14 @@ +import 'package:models/models.dart'; + +abstract class IAppTipsController { + Future init(); + + /// The next tip to show, or `null` when all tips are dismissed or skipped. + Future get activeTip; + + Future dismissTip(AppTip tip); + + Future completeTips(); + + Future resetTips(); +} diff --git a/packages/core/lib/src/controllers/interfaces/i_product_tour_controller.dart b/packages/core/lib/src/controllers/interfaces/i_product_tour_controller.dart deleted file mode 100644 index 24151c98..00000000 --- a/packages/core/lib/src/controllers/interfaces/i_product_tour_controller.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:models/models.dart'; - -abstract class IProductTourController { - Future init(); - Future get currentStep; - Future nextStep(); - Future previousStep(); - Future completeTour(); - - /// Calls the [IAppStorageService] to reset the tour. - /// Sets the [currentStep] to [ProductTourStep.reset]. - /// Sets the [isCompleted] to [false]. - Future resetTour(); -} diff --git a/packages/core/lib/src/services/implementations/app_storage_service.dart b/packages/core/lib/src/services/implementations/app_storage_service.dart index 0332f202..ddf81ad7 100644 --- a/packages/core/lib/src/services/implementations/app_storage_service.dart +++ b/packages/core/lib/src/services/implementations/app_storage_service.dart @@ -93,9 +93,23 @@ class AppStorageService implements IAppStorageService { ); } + @override + Future get dismissedAppTipsMask async { + return _sharedPreferences.getInt(StorageKeys.dismissedAppTipsMask.key) ?? 0; + } + + @override + Future setDismissedAppTipsMask(int mask) async { + await _sharedPreferences.setInt( + StorageKeys.dismissedAppTipsMask.key, + mask, + ); + } + @override Future resetTour() async { await setCurrentStep(-1); + await setDismissedAppTipsMask(0); await setIsCompleted(false); } diff --git a/packages/core/lib/src/services/interfaces/i_app_storage_service.dart b/packages/core/lib/src/services/interfaces/i_app_storage_service.dart index f12f93bb..57f238b0 100644 --- a/packages/core/lib/src/services/interfaces/i_app_storage_service.dart +++ b/packages/core/lib/src/services/interfaces/i_app_storage_service.dart @@ -6,6 +6,8 @@ abstract class IAppStorageService { Future setCurrentStep(int step); Future get isCompleted; Future setIsCompleted(bool isCompleted); + Future get dismissedAppTipsMask; + Future setDismissedAppTipsMask(int mask); Future resetTour(); Future get isLayoutVertical; diff --git a/packages/core/pubspec.yaml b/packages/core/pubspec.yaml index 7e6f6456..75935bb7 100644 --- a/packages/core/pubspec.yaml +++ b/packages/core/pubspec.yaml @@ -33,7 +33,6 @@ dependencies: package_info_plus: ^9.0.0 path_provider: ^2.1.2 shared_preferences: ^2.2.2 - showcaseview: ^5.0.1 uuid: ^4.3.3 version: ^3.0.2 firebase_storage: ^13.3.0 diff --git a/packages/core/test/mocks.dart b/packages/core/test/mocks.dart index 240fd185..beda7c2d 100644 --- a/packages/core/test/mocks.dart +++ b/packages/core/test/mocks.dart @@ -25,7 +25,7 @@ import 'package:shared_preferences/shared_preferences.dart'; MockSpec(as: #MockFilePicker), MockSpec(as: #MockPathProviderPlatform), MockSpec(as: #MockPackageInfo), - MockSpec(as: #MockProductTourController), + MockSpec(as: #MockAppTipsController), MockSpec(as: #MockSharedPreferences), MockSpec(as: #MockAppStorageService), MockSpec(as: #MockFeedbackRepository), diff --git a/packages/core/test/src/application/product/product_bloc_test.dart b/packages/core/test/src/application/product/product_bloc_test.dart index 42de98e4..9a96a453 100644 --- a/packages/core/test/src/application/product/product_bloc_test.dart +++ b/packages/core/test/src/application/product/product_bloc_test.dart @@ -9,15 +9,12 @@ import '../../../mocks.mocks.dart'; void main() { late ProductBloc productBloc; - late MockProductTourController mockProductTourController; - late MockTutorialRepository mockTutorialRepository; + late MockAppTipsController mockAppTipsController; setUp(() { - mockProductTourController = MockProductTourController(); - mockTutorialRepository = MockTutorialRepository(); + mockAppTipsController = MockAppTipsController(); productBloc = ProductBloc( - mockProductTourController, - mockTutorialRepository, + mockAppTipsController, const NoopAnalyticsService(), ); }); @@ -26,161 +23,78 @@ void main() { productBloc.close(); }); - group('ProductBloc Tour Events', () { + group('ProductBloc Tip Events', () { blocTest( - 'emits [currentStep: step, isLoading: false, errorMessage: null] when OnInit is added', + 'emits active tip when OnInit is added', build: () { when( - mockProductTourController.currentStep, - ).thenAnswer((_) async => ProductTourStep.welcomePopup); + mockAppTipsController.activeTip, + ).thenAnswer((_) async => AppTip.collections); return productBloc; }, act: (bloc) => bloc.add(const ProductEvent.init()), expect: () => [ isA() - .having( - (s) => s.currentStep, - 'currentStep', - ProductTourStep.welcomePopup, - ) + .having((s) => s.activeTip, 'activeTip', AppTip.collections) .having((s) => s.isLoading, 'isLoading', false) .having((s) => s.errorMessage, 'errorMessage', null), ], ); blocTest( - 'emits [currentStep: nextStep, isLoading: false, errorMessage: null] when OnNextStep is added', + 'emits next active tip when OnDismissTip is added', build: () { when( - mockProductTourController.nextStep(), + mockAppTipsController.dismissTip(AppTip.collections), ).thenAnswer((_) async => null); when( - mockProductTourController.currentStep, - ).thenAnswer((_) async => ProductTourStep.showCollection); + mockAppTipsController.activeTip, + ).thenAnswer((_) async => AppTip.addCollection); return productBloc; }, - act: (bloc) => bloc.add(const ProductEvent.nextStep()), + act: (bloc) => bloc.add(const ProductEvent.dismissTip(AppTip.collections)), expect: () => [ isA() - .having( - (s) => s.currentStep, - 'currentStep', - ProductTourStep.showCollection, - ) + .having((s) => s.activeTip, 'activeTip', AppTip.addCollection) .having((s) => s.isLoading, 'isLoading', false) .having((s) => s.errorMessage, 'errorMessage', null), ], ); blocTest( - 'emits [currentStep: previousStep, isLoading: false, errorMessage: null] when OnPreviousStep is added', + 'clears active tip when OnSkipAllTips is added', build: () { when( - mockProductTourController.previousStep(), + mockAppTipsController.completeTips(), ).thenAnswer((_) async => null); - when( - mockProductTourController.currentStep, - ).thenAnswer((_) async => ProductTourStep.welcomePopup); return productBloc; }, - act: (bloc) => bloc.add(const ProductEvent.previousStep()), + act: (bloc) => bloc.add(const ProductEvent.skipAllTips()), expect: () => [ isA() - .having( - (s) => s.currentStep, - 'currentStep', - ProductTourStep.welcomePopup, - ) + .having((s) => s.activeTip, 'activeTip', isNull) .having((s) => s.isLoading, 'isLoading', false) .having((s) => s.errorMessage, 'errorMessage', null), ], ); blocTest( - 'emits [currentStep: noneCompleted, isLoading: false, errorMessage: null] when OnSkipTour is added', + 'reloads tips when OnResetTips is added', build: () { + when(mockAppTipsController.resetTips()).thenAnswer((_) async => null); when( - mockProductTourController.completeTour(), - ).thenAnswer((_) async => null); + mockAppTipsController.activeTip, + ).thenAnswer((_) async => AppTip.collections); return productBloc; }, - act: (bloc) => bloc.add(const ProductEvent.skipTour()), - expect: () => [ - isA() - .having( - (s) => s.currentStep, - 'currentStep', - ProductTourStep.noneCompleted, - ) - .having((s) => s.isLoading, 'isLoading', false) - .having((s) => s.errorMessage, 'errorMessage', null), - ], - ); - - blocTest( - 'emits [isLoading: true, currentStep: reset, isLoading: false, errorMessage: null] when OnResetTour is added', - build: () { - when( - mockProductTourController.resetTour(), - ).thenAnswer((_) async => null); - return productBloc; - }, - act: (bloc) => bloc.add(const ProductEvent.resetTour()), + act: (bloc) => bloc.add(const ProductEvent.resetTips()), expect: () => [ isA().having((s) => s.isLoading, 'isLoading', true), isA() - .having((s) => s.currentStep, 'currentStep', ProductTourStep.reset) + .having((s) => s.activeTip, 'activeTip', AppTip.collections) .having((s) => s.isLoading, 'isLoading', false) .having((s) => s.errorMessage, 'errorMessage', null), ], ); }); - - group('ProductBloc Data Events', () { - final mockTabs = [ - TabsDTO( - id: 1, - title: 'Movies', - subtitle: 'My favorite movies', - timestamp: DateTime.now(), - entries: [], - order: 0, - ), - TabsDTO( - id: 2, - title: 'Books', - subtitle: 'Must-read books', - timestamp: DateTime.now(), - entries: [], - order: 1, - ), - ]; - - blocTest( - 'emits [tabs: tabs, isLoading: false] when OnLoadData is added', - build: () { - when( - mockTutorialRepository.loadTutorialData(), - ).thenAnswer((_) async => mockTabs); - return productBloc; - }, - act: (bloc) => bloc.add(const ProductEvent.onLoadData()), - expect: () => [ - isA() - .having((s) => s.tabs, 'tabs', mockTabs) - .having((s) => s.isLoading, 'isLoading', false), - ], - ); - - blocTest( - 'emits [tabs: null, isLoading: true] when OnClearData is added', - build: () => productBloc, - act: (bloc) => bloc.add(const ProductEvent.onClearData()), - expect: () => [ - isA() - .having((s) => s.tabs, 'tabs', null) - .having((s) => s.isLoading, 'isLoading', true), - ], - ); - }); } diff --git a/packages/core/test/src/controllers/app_tips_controller_test.dart b/packages/core/test/src/controllers/app_tips_controller_test.dart new file mode 100644 index 00000000..f0cb9ff8 --- /dev/null +++ b/packages/core/test/src/controllers/app_tips_controller_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:models/models.dart'; +import 'package:core/src/controllers/implementations/app_tips_controller.dart'; + +import '../../mocks.mocks.dart'; + +void main() { + late AppTipsController controller; + late MockAppStorageService mockAppStorageService; + + setUp(() { + mockAppStorageService = MockAppStorageService(); + controller = AppTipsController(mockAppStorageService); + }); + + group('AppTipsController', () { + group('activeTip', () { + test('returns null when tips are completed', () async { + when(mockAppStorageService.isCompleted).thenAnswer((_) async => true); + + final result = await controller.activeTip; + + expect(result, isNull); + }); + + test('returns first undismissed tip from stored mask', () async { + when(mockAppStorageService.isCompleted).thenAnswer((_) async => false); + when( + mockAppStorageService.dismissedAppTipsMask, + ).thenAnswer((_) async => AppTip.collections.mask); + + final result = await controller.activeTip; + + expect(result, AppTip.addCollection); + }); + + test('migrates legacy tour progress when mask is empty', () async { + when(mockAppStorageService.isCompleted).thenAnswer((_) async => false); + when(mockAppStorageService.dismissedAppTipsMask).thenAnswer((_) async => 0); + when(mockAppStorageService.currentStep).thenAnswer((_) async => 4); + + final result = await controller.activeTip; + + expect(result, AppTip.entryActions); + verify( + mockAppStorageService.setDismissedAppTipsMask( + AppTip.collections.mask | + AppTip.addCollection.mask | + AppTip.addEntry.mask, + ), + ).called(1); + }); + }); + + group('dismissTip', () { + test('marks tip dismissed and completes when last tip is dismissed', () async { + when(mockAppStorageService.isCompleted).thenAnswer((_) async => false); + when(mockAppStorageService.dismissedAppTipsMask).thenAnswer( + (_) async => + AppTip.allDismissedMask & ~AppTip.drawer.mask, + ); + + await controller.dismissTip(AppTip.drawer); + + verify( + mockAppStorageService.setDismissedAppTipsMask(AppTip.allDismissedMask), + ).called(greaterThanOrEqualTo(1)); + verify(mockAppStorageService.setIsCompleted(true)).called(1); + }); + }); + + group('resetTips', () { + test('delegates to storage reset', () async { + await controller.resetTips(); + + verify(mockAppStorageService.resetTour()).called(1); + }); + }); + }); +} diff --git a/packages/core/test/src/controllers/product_tour_controller_test.dart b/packages/core/test/src/controllers/product_tour_controller_test.dart deleted file mode 100644 index f2365468..00000000 --- a/packages/core/test/src/controllers/product_tour_controller_test.dart +++ /dev/null @@ -1,168 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; -import 'package:models/models.dart'; -import 'package:core/src/controllers/implementations/product_tour_controller.dart'; - -import '../../mocks.mocks.dart'; - -void main() { - const stableStepOffset = 1000; - late ProductTourController controller; - late MockAppStorageService mockAppStorageService; - - setUp(() { - mockAppStorageService = MockAppStorageService(); - controller = ProductTourController(mockAppStorageService); - }); - - group('ProductTourController', () { - group('currentStep', () { - test('should return noneCompleted when tour is completed', () async { - when(mockAppStorageService.isCompleted).thenAnswer((_) async => true); - when(mockAppStorageService.currentStep).thenAnswer((_) async => 0); - - final result = await controller.currentStep; - - expect(result, equals(ProductTourStep.noneCompleted)); - }); - - test( - 'should return welcomePopup when current step is negative', - () async { - when( - mockAppStorageService.isCompleted, - ).thenAnswer((_) async => false); - when(mockAppStorageService.currentStep).thenAnswer((_) async => -1); - - final result = await controller.currentStep; - - expect(result, equals(ProductTourStep.welcomePopup)); - }, - ); - - test('should return correct step based on stored stable value', () async { - when(mockAppStorageService.isCompleted).thenAnswer((_) async => false); - when(mockAppStorageService.currentStep).thenAnswer( - (_) async => stableStepOffset + ProductTourStep.showCollection.value, - ); - - final result = await controller.currentStep; - - expect(result, equals(ProductTourStep.showCollection)); - }); - - test( - 'should migrate legacy stored index when step was shifted by new insertion', - () async { - when( - mockAppStorageService.isCompleted, - ).thenAnswer((_) async => false); - // Legacy index for showSettings before showEditAndSearch was inserted. - when(mockAppStorageService.currentStep).thenAnswer((_) async => 8); - - final result = await controller.currentStep; - - expect(result, equals(ProductTourStep.showSettings)); - verify( - mockAppStorageService.setCurrentStep( - stableStepOffset + ProductTourStep.showSettings.value, - ), - ).called(1); - }, - ); - - test('should encode non-shifted legacy index on read', () async { - when(mockAppStorageService.isCompleted).thenAnswer((_) async => false); - when(mockAppStorageService.currentStep).thenAnswer((_) async => 3); - - final result = await controller.currentStep; - - expect(result, equals(ProductTourStep.addNewCollection)); - verify( - mockAppStorageService.setCurrentStep( - stableStepOffset + ProductTourStep.addNewCollection.value, - ), - ).called(1); - }); - }); - - group('nextStep', () { - test('should move to next step when not at last step', () async { - when(mockAppStorageService.currentStep).thenAnswer( - (_) async => stableStepOffset, - ); - when(mockAppStorageService.isCompleted).thenAnswer((_) async => false); - - await controller.nextStep(); - - verify( - mockAppStorageService.setCurrentStep(stableStepOffset + 1), - ).called(1); - }); - - test('should move to thanks popup when at close settings', () async { - when( - mockAppStorageService.currentStep, - ).thenAnswer((_) async => stableStepOffset + 13); - when(mockAppStorageService.isCompleted).thenAnswer((_) async => false); - - await controller.nextStep(); - - verify( - mockAppStorageService.setCurrentStep(stableStepOffset + 14), - ).called(1); - }); - - test('should complete tour when at thanks popup', () async { - when( - mockAppStorageService.currentStep, - ).thenAnswer((_) async => stableStepOffset + 14); - when(mockAppStorageService.isCompleted).thenAnswer((_) async => false); - - await controller.nextStep(); - - verify(mockAppStorageService.setIsCompleted(true)).called(1); - }); - }); - - group('previousStep', () { - test('should move to previous step when not at first step', () async { - when(mockAppStorageService.currentStep).thenAnswer( - (_) async => stableStepOffset + 1, - ); - when(mockAppStorageService.isCompleted).thenAnswer((_) async => false); - - await controller.previousStep(); - - verify( - mockAppStorageService.setCurrentStep(stableStepOffset), - ).called(1); - }); - - test('should throw exception when at first step', () async { - when( - mockAppStorageService.currentStep, - ).thenAnswer((_) async => stableStepOffset); - when(mockAppStorageService.isCompleted).thenAnswer((_) async => false); - - expect(() => controller.previousStep(), throwsException); - }); - }); - - group('completeTour', () { - test('should set isCompleted to true', () async { - await controller.completeTour(); - - verify(mockAppStorageService.setIsCompleted(true)).called(1); - }); - }); - - group('resetTour', () { - test('should reset tour state', () async { - await controller.resetTour(); - - verify(mockAppStorageService.resetTour()).called(1); - }); - }); - }); -} diff --git a/packages/core/test/src/controllers/utils/app_tips_storage_codec_test.dart b/packages/core/test/src/controllers/utils/app_tips_storage_codec_test.dart new file mode 100644 index 00000000..bb38599f --- /dev/null +++ b/packages/core/test/src/controllers/utils/app_tips_storage_codec_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:models/models.dart'; +import 'package:core/src/controllers/implementations/utils/app_tips_storage_codec.dart'; + +void main() { + group('AppTipsStorageCodec', () { + test('returns zero for legacy welcome state', () { + expect(AppTipsStorageCodec.migrateLegacyTourProgress(-1), 0); + }); + + test('maps legacy collection step to collections tip dismissed', () { + expect( + AppTipsStorageCodec.migrateLegacyTourProgress(1), + AppTip.collections.mask, + ); + }); + + test('maps legacy thanks popup to all tips dismissed', () { + expect( + AppTipsStorageCodec.migrateLegacyTourProgress(14), + AppTip.allDismissedMask, + ); + }); + }); +} From b308a2e2763859352e73b7207216290cf1682723 Mon Sep 17 00:00:00 2001 From: Zander Kotze Date: Mon, 15 Jun 2026 15:55:47 +0200 Subject: [PATCH 3/5] refactor(tutorial): show non-blocking tips on home instead of sandbox tour Co-authored-by: Cursor --- CHANGELOG.md | 6 + .../lib/app/engine/app_router.dart | 1 - .../lib/app/engine/static_keys.dart | 1 - apps/multichoice/lib/i18n/en.i18n.json | 22 +- apps/multichoice/lib/i18n/nl.i18n.json | 22 +- .../drawer/widgets/more_section.dart | 24 +- .../lib/presentation/home/home_page.dart | 83 +++--- .../home/widgets/app_tips_banner.dart | 53 ++++ .../home/widgets/app_tips_handler.dart | 52 ++++ .../home/widgets/continue_tour_modal.dart | 59 ---- .../home/widgets/welcome_modal.dart | 18 +- .../home/widgets/welcome_modal_handler.dart | 48 +--- .../presentation/tutorial/tutorial_page.dart | 136 --------- .../presentation/tutorial/widgets/export.dart | 5 - .../tutorial/widgets/thanks_modal.dart | 48 ---- .../tutorial/widgets/tutorial_banner.dart | 37 --- .../tutorial/widgets/tutorial_body.dart | 219 --------------- .../tutorial/widgets/tutorial_drawer.dart | 94 ------- .../widgets/tutorial_welcome_modal.dart | 48 ---- .../lib/utils/product_tour/product_tour.dart | 260 ------------------ .../utils/product_tour/product_tour_keys.dart | 40 --- .../product_tour/tour_widget_wrapper.dart | 67 ----- .../utils/_get_product_tour_data.dart | 123 --------- .../utils/get_product_tour_key.dart | 40 --- apps/multichoice/pubspec.yaml | 1 - .../helpers/fake_app_storage_service.dart | 6 + 26 files changed, 212 insertions(+), 1301 deletions(-) create mode 100644 apps/multichoice/lib/presentation/home/widgets/app_tips_banner.dart create mode 100644 apps/multichoice/lib/presentation/home/widgets/app_tips_handler.dart delete mode 100644 apps/multichoice/lib/presentation/home/widgets/continue_tour_modal.dart delete mode 100644 apps/multichoice/lib/presentation/tutorial/tutorial_page.dart delete mode 100644 apps/multichoice/lib/presentation/tutorial/widgets/export.dart delete mode 100644 apps/multichoice/lib/presentation/tutorial/widgets/thanks_modal.dart delete mode 100644 apps/multichoice/lib/presentation/tutorial/widgets/tutorial_banner.dart delete mode 100644 apps/multichoice/lib/presentation/tutorial/widgets/tutorial_body.dart delete mode 100644 apps/multichoice/lib/presentation/tutorial/widgets/tutorial_drawer.dart delete mode 100644 apps/multichoice/lib/presentation/tutorial/widgets/tutorial_welcome_modal.dart delete mode 100644 apps/multichoice/lib/utils/product_tour/product_tour.dart delete mode 100644 apps/multichoice/lib/utils/product_tour/product_tour_keys.dart delete mode 100644 apps/multichoice/lib/utils/product_tour/tour_widget_wrapper.dart delete mode 100644 apps/multichoice/lib/utils/product_tour/utils/_get_product_tour_data.dart delete mode 100644 apps/multichoice/lib/utils/product_tour/utils/get_product_tour_key.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 085e942c..d282843d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# 314 - Improve Tutorial UX + +- Replaced the blocking showcase tutorial with dismissible contextual tips on the home screen; users can interact with the app while tips are visible +- Removed the dedicated tutorial sandbox page, `showcaseview` dependency, and linear `ProductTourStep` flow; drawer "Show app tips" replays tips in place +- Added `AppTip` model, `IAppTipsController`, legacy tour migration, and updated welcome modal copy (en/nl) + # Localization, App Icon, DevOps - Completed slang string migration (#388, child of #372): localized remaining presentation strings across changelog, drawer, home, search, tutorial, data transfer, delete modal, and tooltips; added `localizeCoreMessage()` to map core validation/auth/feedback errors to en/nl at the presentation boundary; Dutch feedback strings aligned with English; forgot-password errors localized; dismiss tooltip required on `DismissibleBannerBar` diff --git a/apps/multichoice/lib/app/engine/app_router.dart b/apps/multichoice/lib/app/engine/app_router.dart index 5362c3cb..45083e44 100644 --- a/apps/multichoice/lib/app/engine/app_router.dart +++ b/apps/multichoice/lib/app/engine/app_router.dart @@ -19,7 +19,6 @@ class AppRouter extends RootStackRouter { AutoRoute(page: DataTransferScreenRoute.page), AutoRoute(page: EditTabPageRoute.page), AutoRoute(page: EditEntryPageRoute.page), - AutoRoute(page: TutorialPageRoute.page), AutoRoute(page: FeedbackPageRoute.page), AutoRoute(page: ProfilePageRoute.page), AutoRoute(page: AccountDeletionPageRoute.page), diff --git a/apps/multichoice/lib/app/engine/static_keys.dart b/apps/multichoice/lib/app/engine/static_keys.dart index 3b99f125..ef5032d2 100644 --- a/apps/multichoice/lib/app/engine/static_keys.dart +++ b/apps/multichoice/lib/app/engine/static_keys.dart @@ -1,4 +1,3 @@ import 'package:flutter/material.dart'; final scaffoldKey = GlobalKey(); -final scaffoldKeyTutorial = GlobalKey(); diff --git a/apps/multichoice/lib/i18n/en.i18n.json b/apps/multichoice/lib/i18n/en.i18n.json index 8d727df3..a5f6c1f2 100644 --- a/apps/multichoice/lib/i18n/en.i18n.json +++ b/apps/multichoice/lib/i18n/en.i18n.json @@ -34,8 +34,8 @@ "appearance": "Appearance", "data": "Data", "more": "More", - "restartTutorial": "Restart Tutorial", - "restartTutorialDesc": "Temporarily switches to demo data to show app features, then restores your original data", + "restartTutorial": "Show app tips", + "restartTutorialDesc": "Replay helpful tips about using Multichoice on the home screen", "sendFeedback": "Send Feedback", "changelog": "Changelog", "about": "About", @@ -96,7 +96,9 @@ "finishTour": "Finish Tour", "continueTour": "Continue Tour", "welcomeToMultichoice": "Welcome to Multichoice", - "welcomeToMultichoiceBody": "Multichoice helps you organize your thoughts and ideas into customizable collections. Would you like to follow a quick tutorial to learn how to use the app?", + "welcomeToMultichoiceBody": "Multichoice helps you organize your thoughts and ideas into customizable collections. We can show a few quick tips as you explore — you can dismiss them anytime and keep using the app.", + "skipTips": "Skip tips", + "showTips": "Show tips", "signedInSuccessfully": "Signed in successfully!", "storageDataClearedSuccessfully": "Storage data cleared successfully", "deletionRequestSubmitted": "Deletion request submitted. You have been signed out.", @@ -266,6 +268,20 @@ "stay": "Stay", "exit": "Exit" }, + "tips": { + "collectionsTitle": "Your collections", + "collectionsBody": "Collections group related items together. Swipe horizontally to browse them.", + "addCollectionTitle": "Add a collection", + "addCollectionBody": "Tap here anytime to create a new collection for a topic or category.", + "addEntryTitle": "Add an item", + "addEntryBody": "Use the plus button inside a collection to add a new entry.", + "entryActionsTitle": "Item shortcuts", + "entryActionsBody": "Tap to view details, double-tap to edit, and long-press for more options.", + "editAndSearchTitle": "Edit and search", + "editAndSearchBody": "Use edit mode to reorder collections and entries, or search across everything.", + "drawerTitle": "Settings and more", + "drawerBody": "Open the menu for appearance, data tools, feedback, and other options." + }, "feedback": { "sendFeedback": "Send Feedback", "thankYouMessage": "Thank you for your feedback!", diff --git a/apps/multichoice/lib/i18n/nl.i18n.json b/apps/multichoice/lib/i18n/nl.i18n.json index 46ba4bbc..3abcf7c0 100644 --- a/apps/multichoice/lib/i18n/nl.i18n.json +++ b/apps/multichoice/lib/i18n/nl.i18n.json @@ -35,8 +35,8 @@ "appearance": "Weergave", "data": "Gegevens", "more": "Meer", - "restartTutorial": "Herstart Tutorial", - "restartTutorialDesc": "Schakelt tijdelijk over naar demogegevens om app-functies te tonen, en herstelt daarna uw originele gegevens", + "restartTutorial": "Toon app-tips", + "restartTutorialDesc": "Speel nuttige tips over het gebruik van Multichoice op het startscherm opnieuw af", "sendFeedback": "Stuur Feedback", "changelog": "Wijzigingslogboek", "about": "Over", @@ -97,7 +97,9 @@ "finishTour": "Voltooi tour", "continueTour": "Doorgaan met tour", "welcomeToMultichoice": "Welkom bij Multichoice", - "welcomeToMultichoiceBody": "Multichoice helpt je je gedachten en ideeën te organiseren in aanpasbare collecties. Wil je een korte tutorial volgen om te leren hoe je de app gebruikt?", + "welcomeToMultichoiceBody": "Multichoice helpt je je gedachten en ideeën te organiseren in aanpasbare collecties. We kunnen een paar korte tips tonen terwijl je de app verkent — je kunt ze altijd sluiten en gewoon verder gaan.", + "skipTips": "Tips overslaan", + "showTips": "Toon tips", "signedInSuccessfully": "Succesvol ingelogd!", "storageDataClearedSuccessfully": "Opslaggegevens succesvol gewist", "deletionRequestSubmitted": "Verzoek tot verwijdering ingediend. Je bent uitgelogd.", @@ -267,6 +269,20 @@ "stay": "Blijven", "exit": "Afsluiten" }, + "tips": { + "collectionsTitle": "Je collecties", + "collectionsBody": "Collecties groeperen gerelateerde items. Veeg horizontaal om ze te bekijken.", + "addCollectionTitle": "Collectie toevoegen", + "addCollectionBody": "Tik hier om een nieuwe collectie voor een onderwerp of categorie te maken.", + "addEntryTitle": "Item toevoegen", + "addEntryBody": "Gebruik de plusknop in een collectie om een nieuw item toe te voegen.", + "entryActionsTitle": "Item-snelkoppelingen", + "entryActionsBody": "Tik om details te bekijken, dubbeltik om te bewerken en houd ingedrukt voor meer opties.", + "editAndSearchTitle": "Bewerken en zoeken", + "editAndSearchBody": "Gebruik de bewerkmodus om collecties en items te herschikken, of zoek in alles.", + "drawerTitle": "Instellingen en meer", + "drawerBody": "Open het menu voor weergave, gegevenstools, feedback en andere opties." + }, "feedback": { "sendFeedback": "Stuur feedback", "thankYouMessage": "Bedankt voor je feedback!", diff --git a/apps/multichoice/lib/presentation/drawer/widgets/more_section.dart b/apps/multichoice/lib/presentation/drawer/widgets/more_section.dart index 7660ff31..9648a2c8 100644 --- a/apps/multichoice/lib/presentation/drawer/widgets/more_section.dart +++ b/apps/multichoice/lib/presentation/drawer/widgets/more_section.dart @@ -33,27 +33,9 @@ class MoreSection extends StatelessWidget { ), trailing: IconButton( onPressed: () async { - final appLayout = context.read(); - final originalLayout = appLayout.isLayoutVertical; - await appLayout.setLayoutVertical(isVertical: false); - - await Future.value( - coreSl().resetTour(), - ).whenComplete(() async { - if (context.mounted) { - Navigator.of(context).pop(); - - await context.router.push( - TutorialPageRoute( - onCallback: () async { - await appLayout.setLayoutVertical( - isVertical: originalLayout, - ); - }, - ), - ); - } - }); + Navigator.of(context).pop(); + if (!context.mounted) return; + context.read().add(const ProductEvent.resetTips()); }, icon: const Icon( Icons.refresh_outlined, diff --git a/apps/multichoice/lib/presentation/home/home_page.dart b/apps/multichoice/lib/presentation/home/home_page.dart index 837fd44d..55b8b475 100644 --- a/apps/multichoice/lib/presentation/home/home_page.dart +++ b/apps/multichoice/lib/presentation/home/home_page.dart @@ -9,6 +9,7 @@ import 'package:multichoice/i18n/strings.g.dart'; import 'package:multichoice/layouts/export.dart'; import 'package:multichoice/presentation/drawer/home_drawer.dart'; import 'package:multichoice/presentation/home/utils/trigger_edit_mode_haptic.dart'; +import 'package:multichoice/presentation/home/widgets/app_tips_handler.dart'; import 'package:multichoice/presentation/home/widgets/home_app_bar.dart'; import 'package:multichoice/presentation/home/widgets/home_promotional_banners.dart'; import 'package:multichoice/presentation/home/widgets/update_modal_handler.dart'; @@ -18,7 +19,6 @@ import 'package:multichoice/presentation/shared/widgets/forms/reusable_form.dart import 'package:multichoice/presentation/shared/widgets/modals/delete_modal.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:reorderable_grid/reorderable_grid.dart'; -import 'package:showcaseview/showcaseview.dart'; import 'package:ui_kit/ui_kit.dart'; part 'utils/_check_and_request_permissions.dart'; @@ -36,17 +36,11 @@ class HomePage extends StatelessWidget { return UpdateModalHandler( builder: (_) => WelcomeModalHandler( builder: (_) => const _HomePage(), - onSkipTour: () async { - context.read().add(const ProductEvent.skipTour()); + onSkipTips: () async { + context.read().add(const ProductEvent.skipAllTips()); }, - onFollowTutorial: () async { - await context.router.push( - TutorialPageRoute( - onCallback: () { - context.read().add(const HomeEvent.onGetTabs()); - }, - ), - ); + onStartTips: () async { + context.read().add(const ProductEvent.init()); }, ), ); @@ -67,41 +61,38 @@ class _HomePageState extends State<_HomePage> { Widget build(BuildContext context) { return AnalyticsPageTracker( page: AnalyticsPage.home, - // this ShowCaseWidget is here to fix an issue where it complains - // about ShowCaseView context not being available - // ignore: deprecated_member_use - child: ShowCaseWidget( - builder: (context) => BlocBuilder( - buildWhen: (previous, current) => - previous.isEditMode != current.isEditMode, - builder: (context, state) { - return PopScope( - canPop: !state.isEditMode && !_isDrawerOpen, - onPopInvokedWithResult: (didPop, _) { - if (didPop) return; + child: BlocBuilder( + buildWhen: (previous, current) => + previous.isEditMode != current.isEditMode, + builder: (context, state) { + return PopScope( + canPop: !state.isEditMode && !_isDrawerOpen, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; - if (_isDrawerOpen) { - Navigator.of(context).pop(); - return; - } + if (_isDrawerOpen) { + Navigator.of(context).pop(); + return; + } - if (state.isEditMode) { - context.read().add( - const HomeEvent.onToggleEditMode(), - ); - } + if (state.isEditMode) { + context.read().add( + const HomeEvent.onToggleEditMode(), + ); + } + }, + child: Scaffold( + key: scaffoldKey, + onDrawerChanged: (isOpened) { + if (_isDrawerOpen == isOpened) return; + setState(() { + _isDrawerOpen = isOpened; + }); }, - child: Scaffold( - key: scaffoldKey, - onDrawerChanged: (isOpened) { - if (_isDrawerOpen == isOpened) return; - setState(() { - _isDrawerOpen = isOpened; - }); - }, - appBar: const HomeAppBar(), - drawer: const HomeDrawer(), - body: const Column( + appBar: const HomeAppBar(), + drawer: const HomeDrawer(), + body: AppTipsHandler( + builder: (_) => const Column( children: [ HomePromotionalBanners(), Expanded( @@ -112,9 +103,9 @@ class _HomePageState extends State<_HomePage> { ], ), ), - ); - }, - ), + ), + ); + }, ), ); } diff --git a/apps/multichoice/lib/presentation/home/widgets/app_tips_banner.dart b/apps/multichoice/lib/presentation/home/widgets/app_tips_banner.dart new file mode 100644 index 00000000..50c285f1 --- /dev/null +++ b/apps/multichoice/lib/presentation/home/widgets/app_tips_banner.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:models/models.dart'; +import 'package:multichoice/app/export.dart'; +import 'package:multichoice/i18n/strings.g.dart'; +import 'package:ui_kit/ui_kit.dart'; + +class AppTipsBanner extends StatelessWidget { + const AppTipsBanner({ + required this.tip, + required this.onDismiss, + super.key, + }); + + final AppTip tip; + final VoidCallback onDismiss; + + @override + Widget build(BuildContext context) { + final colors = context.theme.appColors; + final strings = _stringsForTip(context, tip); + + return DismissibleBannerBar( + variant: BannerBarVariant.pill, + title: strings.title, + body: Text( + strings.body, + style: context.theme.appTextTheme.bodyMedium, + ), + onDismiss: onDismiss, + backgroundColor: colors.primary!.withValues(alpha: 0.12), + dismissTooltip: context.t.common.dismiss, + leading: Icon( + Icons.lightbulb_outline, + color: colors.primary, + ), + ); + } + + ({String title, String body}) _stringsForTip( + BuildContext context, + AppTip tip, + ) { + final tips = context.t.tips; + return switch (tip) { + AppTip.collections => (title: tips.collectionsTitle, body: tips.collectionsBody), + AppTip.addCollection => (title: tips.addCollectionTitle, body: tips.addCollectionBody), + AppTip.addEntry => (title: tips.addEntryTitle, body: tips.addEntryBody), + AppTip.entryActions => (title: tips.entryActionsTitle, body: tips.entryActionsBody), + AppTip.editAndSearch => (title: tips.editAndSearchTitle, body: tips.editAndSearchBody), + AppTip.drawer => (title: tips.drawerTitle, body: tips.drawerBody), + }; + } +} diff --git a/apps/multichoice/lib/presentation/home/widgets/app_tips_handler.dart b/apps/multichoice/lib/presentation/home/widgets/app_tips_handler.dart new file mode 100644 index 00000000..67162b9e --- /dev/null +++ b/apps/multichoice/lib/presentation/home/widgets/app_tips_handler.dart @@ -0,0 +1,52 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:multichoice/presentation/home/widgets/app_tips_banner.dart'; + +class AppTipsHandler extends StatefulWidget { + const AppTipsHandler({ + required this.builder, + super.key, + }); + + final WidgetBuilder builder; + + @override + State createState() => _AppTipsHandlerState(); +} + +class _AppTipsHandlerState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + context.read().add(const ProductEvent.init()); + }); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (previous, current) => previous.activeTip != current.activeTip, + builder: (context, state) { + final activeTip = state.activeTip; + + return Column( + children: [ + if (activeTip != null) + AppTipsBanner( + tip: activeTip, + onDismiss: () { + context.read().add( + ProductEvent.dismissTip(activeTip), + ); + }, + ), + Expanded(child: widget.builder(context)), + ], + ); + }, + ); + } +} diff --git a/apps/multichoice/lib/presentation/home/widgets/continue_tour_modal.dart b/apps/multichoice/lib/presentation/home/widgets/continue_tour_modal.dart deleted file mode 100644 index 924b48b4..00000000 --- a/apps/multichoice/lib/presentation/home/widgets/continue_tour_modal.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:multichoice/app/export.dart'; -import 'package:multichoice/i18n/strings.g.dart'; -import 'package:ui_kit/ui_kit.dart'; - -class ContinueTourModal extends StatelessWidget { - const ContinueTourModal({ - required this.onFinishTour, - required this.onContinueTour, - super.key, - }); - - final VoidCallback onFinishTour; - final VoidCallback onContinueTour; - - @override - Widget build(BuildContext context) { - return PopScope( - canPop: false, - child: Dialog( - child: Padding( - padding: allPadding24, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - context.t.common.continueTutorial, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - gap16, - Text( - context.t.common.continueTutorialBody, - textAlign: TextAlign.center, - style: context.theme.appTextTheme.bodyLarge, - ), - gap24, - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - TextButton( - onPressed: onFinishTour, - child: Text(context.t.common.finishTour), - ), - ElevatedButton( - onPressed: onContinueTour, - child: Text(context.t.common.continueTour), - ), - ], - ), - ], - ), - ), - ), - ); - } -} diff --git a/apps/multichoice/lib/presentation/home/widgets/welcome_modal.dart b/apps/multichoice/lib/presentation/home/widgets/welcome_modal.dart index fa80d8ce..ffffe0e0 100644 --- a/apps/multichoice/lib/presentation/home/widgets/welcome_modal.dart +++ b/apps/multichoice/lib/presentation/home/widgets/welcome_modal.dart @@ -7,13 +7,13 @@ import 'package:ui_kit/ui_kit.dart'; class WelcomeModal extends StatelessWidget { const WelcomeModal({ - required this.onGoHome, - required this.onFollowTutorial, + required this.onSkipTips, + required this.onStartTips, super.key, }); - final VoidCallback onGoHome; - final VoidCallback onFollowTutorial; + final VoidCallback onSkipTips; + final VoidCallback onStartTips; @override Widget build(BuildContext context) { @@ -52,9 +52,9 @@ class WelcomeModal extends StatelessWidget { source: 'welcome_modal', ), ); - onGoHome(); + onSkipTips(); }, - child: Text(context.t.common.goHome), + child: Text(context.t.common.skipTips), ), ElevatedButton( onPressed: () async { @@ -63,12 +63,12 @@ class WelcomeModal extends StatelessWidget { page: AnalyticsPage.home, button: AnalyticsButton.followTutorial, action: AnalyticsAction.tap, - source: 'tutorial', + source: 'welcome_modal', ), ); - onFollowTutorial(); + onStartTips(); }, - child: Text(context.t.tutorial.followTutorial), + child: Text(context.t.common.showTips), ), ], ), diff --git a/apps/multichoice/lib/presentation/home/widgets/welcome_modal_handler.dart b/apps/multichoice/lib/presentation/home/widgets/welcome_modal_handler.dart index a2e532be..1d6e39d3 100644 --- a/apps/multichoice/lib/presentation/home/widgets/welcome_modal_handler.dart +++ b/apps/multichoice/lib/presentation/home/widgets/welcome_modal_handler.dart @@ -1,20 +1,18 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; -import 'package:models/models.dart'; -import 'package:multichoice/presentation/home/widgets/continue_tour_modal.dart'; import 'package:multichoice/presentation/home/widgets/welcome_modal.dart'; class WelcomeModalHandler extends StatefulWidget { const WelcomeModalHandler({ required this.builder, - required this.onSkipTour, - required this.onFollowTutorial, + required this.onSkipTips, + required this.onStartTips, super.key, }); final WidgetBuilder builder; - final Future Function() onSkipTour; - final Future Function() onFollowTutorial; + final Future Function() onSkipTips; + final Future Function() onStartTips; @override State createState() => _WelcomeModalHandlerState(); @@ -25,54 +23,24 @@ class _WelcomeModalHandlerState extends State { Future _checkAndShowWelcomeModal(BuildContext context) async { final appStorageService = coreSl(); - final productTourController = coreSl(); final isExistingUser = await appStorageService.isExistingUser; final isCompleted = await appStorageService.isCompleted; - final currentStep = await productTourController.currentStep; - - final hasStartedTutorial = - currentStep != ProductTourStep.welcomePopup && - currentStep != ProductTourStep.noneCompleted && - currentStep != ProductTourStep.reset; if (!isExistingUser && !isCompleted && context.mounted) { - if (hasStartedTutorial) { - await showDialog( - context: context, - barrierDismissible: false, - builder: (context) => ContinueTourModal( - onFinishTour: () async { - if (context.mounted) { - Navigator.of(context).pop(); - await widget.onSkipTour(); - } - }, - onContinueTour: () async { - if (context.mounted) { - Navigator.of(context).pop(); - await widget.onFollowTutorial(); - } - }, - ), - ); - - return; - } - await showDialog( context: context, barrierDismissible: false, builder: (context) => WelcomeModal( - onGoHome: () async { + onSkipTips: () async { if (context.mounted) { Navigator.of(context).pop(); - await widget.onSkipTour(); + await widget.onSkipTips(); } }, - onFollowTutorial: () async { + onStartTips: () async { if (context.mounted) { Navigator.of(context).pop(); - await widget.onFollowTutorial(); + await widget.onStartTips(); } }, ), diff --git a/apps/multichoice/lib/presentation/tutorial/tutorial_page.dart b/apps/multichoice/lib/presentation/tutorial/tutorial_page.dart deleted file mode 100644 index fa682048..00000000 --- a/apps/multichoice/lib/presentation/tutorial/tutorial_page.dart +++ /dev/null @@ -1,136 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:core/core.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:models/models.dart'; -import 'package:multichoice/app/export.dart'; -import 'package:multichoice/app/view/analytics/analytics_page_tracker.dart'; -import 'package:multichoice/i18n/strings.g.dart'; -import 'package:multichoice/presentation/tutorial/widgets/export.dart'; -import 'package:multichoice/utils/product_tour/product_tour.dart'; -import 'package:multichoice/utils/product_tour/tour_widget_wrapper.dart'; -import 'package:provider/provider.dart'; - -@RoutePage() -class TutorialPage extends StatefulWidget { - const TutorialPage({ - required this.onCallback, - super.key, - }); - - final void Function() onCallback; - - @override - State createState() => _TutorialPageState(); -} - -class _TutorialPageState extends State { - late final ProductBloc _productBloc; - - @override - void initState() { - super.initState(); - - /// Dispatch init events once so that rebuilds do not re-trigger - /// them and cause inconsistent tour state. - _productBloc = coreSl() - ..add(const ProductEvent.init()) - ..add(const ProductEvent.onLoadData()); - } - - @override - Widget build(BuildContext context) { - return MultiProvider( - providers: [ - /// using BlocProvider.value to avoid the issue where it tries - /// to add events that is already closed. - BlocProvider.value( - value: _productBloc, - ), - BlocProvider.value( - value: coreSl(), - ), - ], - child: AnalyticsPageTracker( - page: AnalyticsPage.tutorial, - child: ProductTour( - onTourComplete: ({required shouldRestoreData}) { - if (shouldRestoreData) { - widget.onCallback.call(); - context.router.popUntilRoot(); - } - }, - builder: (_) { - return Scaffold( - key: scaffoldKeyTutorial, - appBar: AppBar( - title: Text(context.t.about.appName), - leading: TourWidgetWrapper( - step: ProductTourStep.showSettings, - child: IconButton( - onPressed: () async { - await coreSl().logEvent( - const UiActionEventData( - page: AnalyticsPage.tutorial, - button: AnalyticsButton.settings, - action: AnalyticsAction.open, - ), - ); - scaffoldKeyTutorial.currentState?.openDrawer(); - }, - tooltip: TooltipEnums.settings.label(context.t), - icon: const Icon(Icons.settings_outlined), - ), - ), - actions: [ - TourWidgetWrapper( - step: ProductTourStep.showEditAndSearch, - child: Row( - children: [ - IconButton( - onPressed: () async { - await coreSl().logEvent( - const UiActionEventData( - page: AnalyticsPage.tutorial, - button: AnalyticsButton.editOrder, - action: AnalyticsAction.open, - ), - ); - }, - tooltip: TooltipEnums.editOrder.label(context.t), - icon: const Icon(Icons.edit_outlined), - ), - IconButton( - onPressed: () async { - await coreSl().logEvent( - const UiActionEventData( - page: AnalyticsPage.tutorial, - button: AnalyticsButton.search, - action: AnalyticsAction.open, - ), - ); - }, - tooltip: TooltipEnums.search.label(context.t), - icon: const Icon(Icons.search_outlined), - ), - ], - ), - ), - ], - ), - drawer: const TutorialDrawer(), - body: const SafeArea( - child: Stack( - children: [ - TutorialBody(), - TutorialBanner(), - ], - ), - ), - ); - }, - ), - ), - ); - } -} diff --git a/apps/multichoice/lib/presentation/tutorial/widgets/export.dart b/apps/multichoice/lib/presentation/tutorial/widgets/export.dart deleted file mode 100644 index 709f08e8..00000000 --- a/apps/multichoice/lib/presentation/tutorial/widgets/export.dart +++ /dev/null @@ -1,5 +0,0 @@ -export 'thanks_modal.dart'; -export 'tutorial_banner.dart'; -export 'tutorial_body.dart'; -export 'tutorial_drawer.dart'; -export 'tutorial_welcome_modal.dart'; diff --git a/apps/multichoice/lib/presentation/tutorial/widgets/thanks_modal.dart b/apps/multichoice/lib/presentation/tutorial/widgets/thanks_modal.dart deleted file mode 100644 index b18d36c0..00000000 --- a/apps/multichoice/lib/presentation/tutorial/widgets/thanks_modal.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:multichoice/app/export.dart'; -import 'package:multichoice/i18n/strings.g.dart'; -import 'package:ui_kit/ui_kit.dart'; - -class ThanksModal extends StatelessWidget { - const ThanksModal({ - required this.onGoHome, - super.key, - }); - - final VoidCallback onGoHome; - - @override - Widget build(BuildContext context) { - return PopScope( - canPop: false, - child: Dialog( - child: Padding( - padding: allPadding24, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - context.t.tutorial.thanksTitle, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - gap16, - Text( - context.t.tutorial.thanksBody, - textAlign: TextAlign.center, - style: context.theme.appTextTheme.bodyLarge, - ), - gap24, - ElevatedButton( - onPressed: onGoHome, - child: Text(context.t.common.goHome), - ), - ], - ), - ), - ), - ); - } -} diff --git a/apps/multichoice/lib/presentation/tutorial/widgets/tutorial_banner.dart b/apps/multichoice/lib/presentation/tutorial/widgets/tutorial_banner.dart deleted file mode 100644 index ec0afb1f..00000000 --- a/apps/multichoice/lib/presentation/tutorial/widgets/tutorial_banner.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:multichoice/app/export.dart'; -import 'package:multichoice/i18n/strings.g.dart'; - -class TutorialBanner extends StatelessWidget { - const TutorialBanner({super.key}); - - @override - Widget build(BuildContext context) { - final primary = context.theme.appColors.primary; - final onPrimary = context.theme.appColors.filledButtonForeground; - - return Positioned( - top: 20, - right: -40, - child: Transform.rotate( - angle: 0.785398, // 45 degrees in radians - child: Container( - color: primary, - padding: const EdgeInsets.symmetric( - horizontal: 40, - vertical: 2, - ), - child: Text( - context.t.tutorial.bannerLabel, - style: TextStyle( - color: onPrimary, - fontSize: 10, - fontWeight: FontWeight.bold, - letterSpacing: 1, - ), - ), - ), - ), - ); - } -} diff --git a/apps/multichoice/lib/presentation/tutorial/widgets/tutorial_body.dart b/apps/multichoice/lib/presentation/tutorial/widgets/tutorial_body.dart deleted file mode 100644 index e330d2e1..00000000 --- a/apps/multichoice/lib/presentation/tutorial/widgets/tutorial_body.dart +++ /dev/null @@ -1,219 +0,0 @@ -import 'package:core/core.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:models/models.dart'; -import 'package:multichoice/app/export.dart'; -import 'package:multichoice/presentation/home/home_page.dart'; -import 'package:multichoice/utils/product_tour/tour_widget_wrapper.dart'; -import 'package:ui_kit/ui_kit.dart'; - -class TutorialBody extends StatelessWidget { - const TutorialBody({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - if (state.isLoading) { - return CircularLoader.small(); - } - - final tabs = state.tabs ?? []; - - return Padding( - padding: horizontal8, - child: CustomScrollView( - controller: ScrollController(), - scrollBehavior: CustomScrollBehaviour(), - slivers: [ - SliverPadding( - padding: top4, - sliver: SliverList.builder( - itemCount: tabs.length, - itemBuilder: (_, index) { - final tab = tabs[index]; - - if (tabs.isNotEmpty && index == 0) { - final step = context - .watch() - .state - .currentStep; - - if (step == ProductTourStep.showCollection) { - return TourWidgetWrapper( - step: ProductTourStep.showCollection, - child: _HorizontalTab(tab: tab), - ); - } else if (step == - ProductTourStep.showCollectionActions) { - return TourWidgetWrapper( - step: ProductTourStep.showCollectionActions, - child: _HorizontalTab(tab: tab), - ); - } - - return _HorizontalTab(tab: tab); - } - - return _HorizontalTab(tab: tab); - }, - ), - ), - const SliverPadding( - padding: bottom24, - sliver: SliverToBoxAdapter( - child: TourWidgetWrapper( - step: ProductTourStep.addNewCollection, - child: NewTab(), - ), - ), - ), - ], - ), - ); - }, - ); - } -} - -class _HorizontalTab extends StatelessWidget { - const _HorizontalTab({ - required this.tab, - }); - - final TabsDTO tab; - - @override - Widget build(BuildContext context) { - final appLayout = context.watch(); - final entries = tab.entries; - final isFirstTab = - context.watch().state.tabs?.first.id == tab.id; - - return Padding( - padding: allPadding4, - child: Container( - decoration: BoxDecoration( - color: context.theme.appColors.primary?.withValues(alpha: 0.8), - borderRadius: borderCircular12, - ), - child: Padding( - padding: allPadding2, - child: SizedBox( - height: UIConstants.horiTabHeight(context), - child: CustomScrollView( - scrollDirection: Axis.horizontal, - controller: ScrollController(), - scrollBehavior: CustomScrollBehaviour(), - slivers: [ - SliverToBoxAdapter( - child: SizedBox( - width: UIConstants.horiTabHeaderWidth(context), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: left4, - child: Text( - tab.title, - style: context.theme.appTextTheme.denseTitle, - ), - ), - if (tab.subtitle.isEmpty) - const SizedBox.shrink() - else - Padding( - padding: left4, - child: Text( - tab.subtitle, - style: context.theme.appTextTheme.denseSubtitle, - ), - ), - const Expanded(child: SizedBox()), - Center( - child: isFirstTab - ? TourWidgetWrapper( - step: ProductTourStep.showCollectionMenu, - child: MenuWidget(tab: tab), - ) - : MenuWidget(tab: tab), - ), - ], - ), - ), - ), - SliverToBoxAdapter( - child: VerticalDivider( - color: context.theme.appColors.secondaryLight, - thickness: 2, - width: 8, - indent: 0, - endIndent: 0, - ), - ), - SliverGrid.builder( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - ), - itemCount: entries.length + 1, - itemBuilder: (context, index) { - if (index == entries.length) { - return isFirstTab - ? TourWidgetWrapper( - step: ProductTourStep.addNewItem, - child: NewEntry( - tabId: tab.id, - ), - ) - : NewEntry(tabId: tab.id); - } - - final entry = entries[index]; - - if (entries.isNotEmpty && index == 0 && isFirstTab) { - final step = context.watch().state.currentStep; - - if (step == ProductTourStep.showItemsInCollection) { - return TourWidgetWrapper( - step: ProductTourStep.showItemsInCollection, - child: EntryCard( - entry: entry, - onDoubleTap: () {}, - isLayoutVertical: appLayout.isLayoutVertical, - ), - ); - } else if (step == ProductTourStep.showItemActions) { - return TourWidgetWrapper( - step: ProductTourStep.showItemActions, - child: EntryCard( - entry: entry, - onDoubleTap: () {}, - isLayoutVertical: appLayout.isLayoutVertical, - ), - ); - } - - return EntryCard( - entry: entry, - onDoubleTap: () {}, - isLayoutVertical: appLayout.isLayoutVertical, - ); - } - - return EntryCard( - entry: entry, - onDoubleTap: () {}, - isLayoutVertical: appLayout.isLayoutVertical, - ); - }, - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/apps/multichoice/lib/presentation/tutorial/widgets/tutorial_drawer.dart b/apps/multichoice/lib/presentation/tutorial/widgets/tutorial_drawer.dart deleted file mode 100644 index 4fa6c2ba..00000000 --- a/apps/multichoice/lib/presentation/tutorial/widgets/tutorial_drawer.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:models/models.dart'; -import 'package:multichoice/app/export.dart'; -import 'package:multichoice/generated/assets.gen.dart'; -import 'package:multichoice/i18n/strings.g.dart'; -import 'package:multichoice/presentation/drawer/widgets/export.dart'; -import 'package:multichoice/utils/product_tour/tour_widget_wrapper.dart'; -import 'package:ui_kit/ui_kit.dart'; - -class TutorialDrawer extends StatelessWidget { - const TutorialDrawer({super.key}); - - @override - Widget build(BuildContext context) { - return Drawer( - width: MediaQuery.sizeOf(context).width, - backgroundColor: context.theme.appColors.background, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - DrawerHeader( - padding: allPadding12, - child: Row( - children: [ - ClipRRect( - borderRadius: borderCircular12, - child: Image.asset( - Assets.images.playstore.path, - width: 48, - height: 48, - ), - ), - gap16, - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - context.t.about.appName, - style: context.appTextTheme.headingMedium, - ), - gap4, - Text( - context.t.drawer.welcomeBack, - style: context.appTextTheme.subtitleMedium, - ), - ], - ), - ), - TourWidgetWrapper( - step: ProductTourStep.closeSettings, - child: IconButton( - onPressed: () { - Navigator.of(context).pop(); - }, - tooltip: TooltipEnums.close.label(context.t), - icon: const Icon( - Icons.close_outlined, - size: 28, - ), - ), - ), - ], - ), - ), - Expanded( - child: ListView( - physics: const NeverScrollableScrollPhysics(), - padding: EdgeInsets.zero, - children: const [ - TourWidgetWrapper( - step: ProductTourStep.showAppearanceSection, - child: AppearanceSection(), - ), - Divider(height: 32), - TourWidgetWrapper( - step: ProductTourStep.showDataSection, - child: DataSection(), - ), - Divider(height: 32), - TourWidgetWrapper( - step: ProductTourStep.showMoreSection, - child: MoreSection(), - ), - ], - ), - ), - const AppVersion(), - ], - ), - ); - } -} diff --git a/apps/multichoice/lib/presentation/tutorial/widgets/tutorial_welcome_modal.dart b/apps/multichoice/lib/presentation/tutorial/widgets/tutorial_welcome_modal.dart deleted file mode 100644 index 1dc34920..00000000 --- a/apps/multichoice/lib/presentation/tutorial/widgets/tutorial_welcome_modal.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:multichoice/app/export.dart'; -import 'package:multichoice/i18n/strings.g.dart'; -import 'package:ui_kit/ui_kit.dart'; - -class TutorialWelcomeModal extends StatelessWidget { - const TutorialWelcomeModal({ - required this.onStart, - super.key, - }); - - final VoidCallback onStart; - - @override - Widget build(BuildContext context) { - return PopScope( - canPop: false, - child: Dialog( - child: Padding( - padding: allPadding24, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - context.t.tutorial.welcomeTitle, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - gap16, - Text( - context.t.tutorial.welcomeBody, - textAlign: TextAlign.center, - style: context.theme.appTextTheme.bodyLarge, - ), - gap24, - ElevatedButton( - onPressed: onStart, - child: Text(context.t.common.start), - ), - ], - ), - ), - ), - ); - } -} diff --git a/apps/multichoice/lib/utils/product_tour/product_tour.dart b/apps/multichoice/lib/utils/product_tour/product_tour.dart deleted file mode 100644 index 3dac016e..00000000 --- a/apps/multichoice/lib/utils/product_tour/product_tour.dart +++ /dev/null @@ -1,260 +0,0 @@ -import 'dart:async'; - -import 'package:core/core.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:models/models.dart'; -import 'package:multichoice/i18n/strings.g.dart'; -import 'package:multichoice/presentation/tutorial/widgets/thanks_modal.dart'; -import 'package:multichoice/presentation/tutorial/widgets/tutorial_welcome_modal.dart'; -import 'package:multichoice/utils/product_tour/utils/get_product_tour_key.dart'; -import 'package:showcaseview/showcaseview.dart'; - -class ProductTour extends StatefulWidget { - const ProductTour({ - required this.builder, - required this.onTourComplete, - super.key, - }); - - final WidgetBuilder builder; - final void Function({required bool shouldRestoreData}) onTourComplete; - - @override - State createState() => _ProductTourState(); -} - -class _ProductTourState extends State { - bool _isShowingDialog = false; - bool _isShowingExitPrompt = false; - bool _isResetRestartInProgress = false; - final IProductTourController _productTourController = - coreSl(); - final GlobalKey _showCaseWidgetKey = - GlobalKey(); - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) async { - if (!mounted) return; - await handleProductTour(context); - }); - } - - @override - Widget build(BuildContext context) { - // ShowCaseWidget is deprecated but migration to ShowcaseView.register() - // requires architectural changes. Keeping current implementation until - // proper migration can be planned. See: showcaseview v5.0.0 changelog - // ignore: deprecated_member_use - return ShowCaseWidget( - key: _showCaseWidgetKey, - builder: (_) { - return BlocListener( - listener: (context, state) async { - if (state.currentStep != ProductTourStep.reset) { - _isResetRestartInProgress = false; - } - - if (state.currentStep == ProductTourStep.reset) { - if (_isResetRestartInProgress) { - return; - } - - _isResetRestartInProgress = true; - - final shouldLoadData = state.tabs == null || state.tabs!.isEmpty; - - if (shouldLoadData) { - context.read().add( - const ProductEvent.onLoadData(), - ); - } - - // Move away from `reset` so tab updates don't repeatedly trigger - // the reset branch while restart is being processed. - context.read().add(const ProductEvent.init()); - await handleProductTour(context, shouldRestart: true); - return; - } else if (state.currentStep == ProductTourStep.welcomePopup) { - await handleProductTour(context); - return; - } else if (state.currentStep == ProductTourStep.thanksPopup) { - await handleProductTour(context); - return; - } - - _startShowcaseForStep(state.currentStep); - }, - child: BlocBuilder( - buildWhen: (previous, current) => - previous.currentStep != current.currentStep, - builder: (context, state) { - final shouldInterceptBack = _shouldInterceptBack( - state.currentStep, - ); - - return PopScope( - canPop: !shouldInterceptBack, - onPopInvokedWithResult: (didPop, _) { - if (!shouldInterceptBack || - didPop || - _isShowingDialog || - _isShowingExitPrompt) { - return; - } - - _isShowingExitPrompt = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) { - _isShowingExitPrompt = false; - return; - } - - unawaited(_showExitTutorialPrompt(context)); - }); - }, - child: widget.builder(context), - ); - }, - ), - ); - }, - ); - } - - bool _shouldInterceptBack(ProductTourStep step) { - return step.value > ProductTourStep.welcomePopup.value && - step.value < ProductTourStep.thanksPopup.value; - } - - void _startShowcaseForStep(ProductTourStep step) { - if (step == ProductTourStep.welcomePopup || - step == ProductTourStep.noneCompleted || - step == ProductTourStep.reset) { - return; - } - - final key = getProductTourKey(step); - if (key == null) return; - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - ShowcaseView.get().startShowCase([key]); - }); - } - - Future handleProductTour( - BuildContext context, { - bool shouldRestart = false, - }) async { - if (_isShowingDialog && !shouldRestart) return; - - await _productTourController.currentStep.then((currentStep) async { - if (!context.mounted || currentStep == ProductTourStep.noneCompleted) { - return; - } - - // Only show welcome modal if we have data - if (currentStep == ProductTourStep.welcomePopup && - !_isShowingDialog && - (context.read().state.tabs?.isNotEmpty ?? false)) { - await _showWelcomeModal(context); - } else if (currentStep == ProductTourStep.thanksPopup && - !_isShowingDialog) { - await _showThanksModal(context); - } - }); - } - - Future _showWelcomeModal(BuildContext context) async { - _isShowingDialog = true; - - await showDialog( - context: context, - barrierDismissible: false, - builder: (dialogContext) => TutorialWelcomeModal( - onStart: () { - Navigator.of(dialogContext, rootNavigator: true).pop(); - context.read().add(const ProductEvent.nextStep()); - }, - ), - ).then((_) { - _isShowingDialog = false; - }); - } - - Future _showThanksModal(BuildContext context) async { - _isShowingDialog = true; - - await showDialog( - context: context, - barrierDismissible: false, - builder: (dialogContext) => ThanksModal( - onGoHome: () async { - if (dialogContext.mounted) { - Navigator.of(dialogContext, rootNavigator: true).pop(); - coreSl().add(const ProductEvent.skipTour()); - widget.onTourComplete(shouldRestoreData: true); - } - }, - ), - ).then((_) { - _isShowingDialog = false; - }); - } - - Future _showExitTutorialPrompt(BuildContext context) async { - // Dismiss any active showcase overlay so the confirmation dialog is - // guaranteed to render as the top-most modal. - ShowcaseView.get().dismiss(); - - final productBloc = context.read(); - - try { - final shouldExit = await showDialog( - context: context, - builder: (dialogContext) { - return AlertDialog( - title: Text(context.t.tutorial.exitTitle), - content: Text(context.t.tutorial.exitBody), - actions: [ - TextButton( - onPressed: () { - Navigator.of(dialogContext, rootNavigator: true).pop(false); - }, - child: Text(context.t.tutorial.stay), - ), - ElevatedButton( - onPressed: () { - Navigator.of(dialogContext, rootNavigator: true).pop(true); - }, - child: Text(context.t.tutorial.exit), - ), - ], - ); - }, - ); - - if (!mounted) { - return; - } - - if (shouldExit != true) { - final currentStep = productBloc.state.currentStep; - - if (_shouldInterceptBack(currentStep)) { - _startShowcaseForStep(currentStep); - } - - return; - } - - productBloc.add(const ProductEvent.skipTour()); - widget.onTourComplete(shouldRestoreData: true); - } finally { - _isShowingExitPrompt = false; - } - } -} diff --git a/apps/multichoice/lib/utils/product_tour/product_tour_keys.dart b/apps/multichoice/lib/utils/product_tour/product_tour_keys.dart deleted file mode 100644 index 0c107045..00000000 --- a/apps/multichoice/lib/utils/product_tour/product_tour_keys.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:flutter/material.dart'; - -class ProductTourKeys { - static final GlobalKey welcomePopup = GlobalKey(debugLabel: 'welcomePopup'); - static final GlobalKey showCollection = GlobalKey( - debugLabel: 'showCollection', - ); - static final GlobalKey showItemsInCollection = GlobalKey( - debugLabel: 'showItemsInCollection', - ); - static final GlobalKey addNewCollection = GlobalKey( - debugLabel: 'addNewCollection', - ); - static final GlobalKey addNewItem = GlobalKey(debugLabel: 'addNewItem'); - static final GlobalKey showItemActions = GlobalKey( - debugLabel: 'showItemActions', - ); - static final GlobalKey showCollectionActions = GlobalKey( - debugLabel: 'showCollectionActions', - ); - static final GlobalKey showCollectionMenu = GlobalKey( - debugLabel: 'showCollectionMenu', - ); - static final GlobalKey showEditAndSearch = GlobalKey( - debugLabel: 'showEditAndSearch', - ); - static final GlobalKey showSettings = GlobalKey(debugLabel: 'showSettings'); - static final GlobalKey showAppearanceSection = GlobalKey( - debugLabel: 'showAppearanceSection', - ); - static final GlobalKey showDataSection = GlobalKey( - debugLabel: 'showDataSection', - ); - static final GlobalKey showMoreSection = GlobalKey( - debugLabel: 'showMoreSection', - ); - static final GlobalKey showDetails = GlobalKey(debugLabel: 'showDetails'); - static final GlobalKey closeSettings = GlobalKey(debugLabel: 'closeSettings'); - static final GlobalKey thanksPopup = GlobalKey(debugLabel: 'thanksPopup'); -} diff --git a/apps/multichoice/lib/utils/product_tour/tour_widget_wrapper.dart b/apps/multichoice/lib/utils/product_tour/tour_widget_wrapper.dart deleted file mode 100644 index 4a9935e6..00000000 --- a/apps/multichoice/lib/utils/product_tour/tour_widget_wrapper.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'package:core/core.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:models/models.dart'; -import 'package:multichoice/app/export.dart'; -import 'package:multichoice/utils/product_tour/utils/get_product_tour_key.dart'; -import 'package:showcaseview/showcaseview.dart'; - -part 'utils/_get_product_tour_data.dart'; - -class TourWidgetWrapper extends StatelessWidget { - const TourWidgetWrapper({ - required this.step, - required this.child, - this.tabId, - super.key, - }); - - final Widget child; - final ProductTourStep step; - final int? tabId; - - @override - Widget build(BuildContext context) { - TooltipPosition getTooltipPosition(Position? step) { - switch (step ?? Position.bottom) { - case Position.top: - return TooltipPosition.top; - case Position.bottom: - return TooltipPosition.bottom; - } - } - - return BlocBuilder( - builder: (context, state) { - if (state.currentStep != step) { - return child; - } - - final key = getProductTourKey(step, tabId: tabId); - final showcaseData = _getProductTourData(step); - final isLightMode = Theme.of(context).brightness == Brightness.light; - - if (key != null && context.mounted) { - return Showcase( - key: key, - title: showcaseData.title, - description: showcaseData.description, - onTargetClick: showcaseData.onTargetClick, - disposeOnTap: showcaseData.disposeOnTap, - disableBarrierInteraction: - showcaseData.disableBarrierInteraction ?? true, - onBarrierClick: showcaseData.onBarrierClick, - overlayOpacity: isLightMode - ? showcaseData.overlayOpacity - : showcaseData.overlayOpacity * 0.25, - overlayColor: showcaseData.overlayColor, - tooltipPosition: getTooltipPosition(showcaseData.tooltipPosition), - child: child, - ); - } - - return child; - }, - ); - } -} diff --git a/apps/multichoice/lib/utils/product_tour/utils/_get_product_tour_data.dart b/apps/multichoice/lib/utils/product_tour/utils/_get_product_tour_data.dart deleted file mode 100644 index 4f208639..00000000 --- a/apps/multichoice/lib/utils/product_tour/utils/_get_product_tour_data.dart +++ /dev/null @@ -1,123 +0,0 @@ -part of '../tour_widget_wrapper.dart'; - -ShowcaseData _getProductTourData(ProductTourStep step) { - switch (step) { - case ProductTourStep.welcomePopup: - return ShowcaseData.empty(); - case ProductTourStep.showCollection: - return ShowcaseData( - description: - 'This is your collection view. Here you can see all your collections.', - onTargetClick: () { - coreSl().add(const ProductEvent.nextStep()); - }, - ); - case ProductTourStep.showItemsInCollection: - return ShowcaseData( - description: - 'Each collection contains items. Click on a collection to see its items.', - onTargetClick: () { - coreSl().add(const ProductEvent.nextStep()); - }, - ); - case ProductTourStep.addNewCollection: - return ShowcaseData( - description: 'Click here to create a new collection.', - onTargetClick: () { - coreSl().add(const ProductEvent.nextStep()); - }, - ); - case ProductTourStep.addNewItem: - return ShowcaseData( - description: 'Add new items to your collection here.', - onTargetClick: () { - coreSl().add(const ProductEvent.nextStep()); - }, - ); - case ProductTourStep.showItemActions: - return ShowcaseData( - description: - 'Each item has actions you can perform. Try them out! Tap to view details, double tap to edit, and long press to open menu.', - onTargetClick: () { - coreSl().add(const ProductEvent.nextStep()); - }, - ); - case ProductTourStep.showCollectionActions: - return ShowcaseData( - description: - 'Collections also have their own set of actions. Long press to delete.', - onTargetClick: () { - coreSl().add(const ProductEvent.nextStep()); - }, - ); - case ProductTourStep.showCollectionMenu: - return ShowcaseData( - description: 'Access collection options through this menu.', - onTargetClick: () { - coreSl().add(const ProductEvent.nextStep()); - }, - ); - case ProductTourStep.showEditAndSearch: - return ShowcaseData( - description: 'Tap here to enter edit mode and search', - onTargetClick: () { - coreSl().add(const ProductEvent.nextStep()); - }, - ); - case ProductTourStep.showSettings: - return ShowcaseData( - description: 'Tap here to access settings and more options here.', - onTargetClick: () { - scaffoldKeyTutorial.currentState?.openDrawer(); - Future.delayed( - const Duration(milliseconds: 350), - () => coreSl().add(const ProductEvent.nextStep()), - ); - }, - ); - case ProductTourStep.showAppearanceSection: - return ShowcaseData( - title: 'Appearance Settings', - description: 'Customize the appearance of your collections here.', - onTargetClick: () { - coreSl().add(const ProductEvent.nextStep()); - }, - tooltipPosition: Position.top, - ); - case ProductTourStep.showDataSection: - return ShowcaseData( - title: 'Data Management', - description: 'Manage your data and backups in this section.', - onTargetClick: () { - coreSl().add(const ProductEvent.nextStep()); - }, - tooltipPosition: Position.top, - ); - case ProductTourStep.showMoreSection: - return ShowcaseData( - title: 'More Options', - description: 'Explore additional features in the More section.', - onTargetClick: () { - coreSl().add(const ProductEvent.nextStep()); - }, - tooltipPosition: Position.top, - ); - case ProductTourStep.closeSettings: - return ShowcaseData( - description: - 'Tap here to close settings to return to your collections.', - onTargetClick: () { - scaffoldKeyTutorial.currentState?.closeDrawer(); - coreSl().add(const ProductEvent.nextStep()); - }, - ); - case ProductTourStep.thanksPopup: - return const ShowcaseData( - description: - "Thanks for completing the tour! You're all set to use MultiChoice.", - ); - case ProductTourStep.noneCompleted: - case ProductTourStep.reset: - return ShowcaseData.empty(); - } -} diff --git a/apps/multichoice/lib/utils/product_tour/utils/get_product_tour_key.dart b/apps/multichoice/lib/utils/product_tour/utils/get_product_tour_key.dart deleted file mode 100644 index 1200b5dc..00000000 --- a/apps/multichoice/lib/utils/product_tour/utils/get_product_tour_key.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:models/models.dart'; -import 'package:multichoice/utils/product_tour/product_tour_keys.dart'; - -GlobalKey? getProductTourKey(dynamic step, {int? tabId}) { - switch (step) { - case ProductTourStep.welcomePopup: - return ProductTourKeys.welcomePopup; - case ProductTourStep.showCollection: - return ProductTourKeys.showCollection; - case ProductTourStep.showItemsInCollection: - return ProductTourKeys.showItemsInCollection; - case ProductTourStep.addNewCollection: - return ProductTourKeys.addNewCollection; - case ProductTourStep.addNewItem: - return ProductTourKeys.addNewItem; - case ProductTourStep.showItemActions: - return ProductTourKeys.showItemActions; - case ProductTourStep.showCollectionActions: - return ProductTourKeys.showCollectionActions; - case ProductTourStep.showCollectionMenu: - return ProductTourKeys.showCollectionMenu; - case ProductTourStep.showEditAndSearch: - return ProductTourKeys.showEditAndSearch; - case ProductTourStep.showSettings: - return ProductTourKeys.showSettings; - case ProductTourStep.showAppearanceSection: - return ProductTourKeys.showAppearanceSection; - case ProductTourStep.showDataSection: - return ProductTourKeys.showDataSection; - case ProductTourStep.showMoreSection: - return ProductTourKeys.showMoreSection; - case ProductTourStep.closeSettings: - return ProductTourKeys.closeSettings; - case ProductTourStep.thanksPopup: - return ProductTourKeys.thanksPopup; - default: - return null; - } -} diff --git a/apps/multichoice/pubspec.yaml b/apps/multichoice/pubspec.yaml index 634d95ec..dbf28b12 100644 --- a/apps/multichoice/pubspec.yaml +++ b/apps/multichoice/pubspec.yaml @@ -33,7 +33,6 @@ dependencies: provider: ^6.1.1 reorderable_grid: ^1.0.13 shared_preferences: ^2.2.2 - showcaseview: ^5.0.1 slang: ^4.14.0 slang_flutter: ^4.14.0 super_clipboard: ^0.9.1 diff --git a/apps/multichoice/test/helpers/fake_app_storage_service.dart b/apps/multichoice/test/helpers/fake_app_storage_service.dart index 62d8d811..bc0612d9 100644 --- a/apps/multichoice/test/helpers/fake_app_storage_service.dart +++ b/apps/multichoice/test/helpers/fake_app_storage_service.dart @@ -19,6 +19,12 @@ class FakeAppStorageService implements IAppStorageService { @override Future setIsCompleted(bool isCompleted) async {} + @override + Future get dismissedAppTipsMask async => 0; + + @override + Future setDismissedAppTipsMask(int mask) async {} + @override Future resetTour() async {} From 19183fb08b8ab6f6165f231b0024a4f6a6da0731 Mon Sep 17 00:00:00 2001 From: Zander Kotze Date: Mon, 15 Jun 2026 16:48:49 +0200 Subject: [PATCH 4/5] fix(tutorial): use non-blocking showcase tips and prevent home rebuild crash Co-authored-by: Cursor --- apps/multichoice/lib/i18n/en.i18n.json | 2 +- apps/multichoice/lib/i18n/nl.i18n.json | 2 +- .../lib/layouts/home_layout/home_layout.dart | 2 + .../lib/layouts/home_layout/tab_layout.dart | 1 + .../widgets/home/horizontal_home.dart | 15 ++- .../widgets/home/vertical_home.dart | 15 ++- .../home_layout/widgets/tab/entries_grid.dart | 40 +++++--- .../lib/presentation/drawer/home_drawer.dart | 13 ++- .../lib/presentation/home/home_page.dart | 73 ++++++++------- .../home/widgets/app_tips_banner.dart | 53 ----------- .../home/widgets/app_tips_handler.dart | 52 ----------- .../home/widgets/home_app_bar.dart | 50 ++++++---- .../lib/utils/app_tips/app_tip_content.dart | 35 +++++++ .../utils/app_tips/app_tip_coordinator.dart | 93 +++++++++++++++++++ .../app_tips/app_tip_drawer_showcase.dart | 29 ++++++ .../lib/utils/app_tips/app_tip_keys.dart | 30 ++++++ .../lib/utils/app_tips/app_tip_showcase.dart | 64 +++++++++++++ apps/multichoice/pubspec.yaml | 1 + 18 files changed, 390 insertions(+), 180 deletions(-) delete mode 100644 apps/multichoice/lib/presentation/home/widgets/app_tips_banner.dart delete mode 100644 apps/multichoice/lib/presentation/home/widgets/app_tips_handler.dart create mode 100644 apps/multichoice/lib/utils/app_tips/app_tip_content.dart create mode 100644 apps/multichoice/lib/utils/app_tips/app_tip_coordinator.dart create mode 100644 apps/multichoice/lib/utils/app_tips/app_tip_drawer_showcase.dart create mode 100644 apps/multichoice/lib/utils/app_tips/app_tip_keys.dart create mode 100644 apps/multichoice/lib/utils/app_tips/app_tip_showcase.dart diff --git a/apps/multichoice/lib/i18n/en.i18n.json b/apps/multichoice/lib/i18n/en.i18n.json index a5f6c1f2..d1dc80fb 100644 --- a/apps/multichoice/lib/i18n/en.i18n.json +++ b/apps/multichoice/lib/i18n/en.i18n.json @@ -280,7 +280,7 @@ "editAndSearchTitle": "Edit and search", "editAndSearchBody": "Use edit mode to reorder collections and entries, or search across everything.", "drawerTitle": "Settings and more", - "drawerBody": "Open the menu for appearance, data tools, feedback, and other options." + "drawerBody": "Open the menu for appearance, data tools, feedback, and more. Tips appear as you explore." }, "feedback": { "sendFeedback": "Send Feedback", diff --git a/apps/multichoice/lib/i18n/nl.i18n.json b/apps/multichoice/lib/i18n/nl.i18n.json index 3abcf7c0..fdc49ccf 100644 --- a/apps/multichoice/lib/i18n/nl.i18n.json +++ b/apps/multichoice/lib/i18n/nl.i18n.json @@ -281,7 +281,7 @@ "editAndSearchTitle": "Bewerken en zoeken", "editAndSearchBody": "Gebruik de bewerkmodus om collecties en items te herschikken, of zoek in alles.", "drawerTitle": "Instellingen en meer", - "drawerBody": "Open het menu voor weergave, gegevenstools, feedback en andere opties." + "drawerBody": "Open het menu voor weergave, gegevenstools, feedback en meer. Tips verschijnen terwijl je de app verkent." }, "feedback": { "sendFeedback": "Stuur feedback", diff --git a/apps/multichoice/lib/layouts/home_layout/home_layout.dart b/apps/multichoice/lib/layouts/home_layout/home_layout.dart index 44d1bbed..32464dc3 100644 --- a/apps/multichoice/lib/layouts/home_layout/home_layout.dart +++ b/apps/multichoice/lib/layouts/home_layout/home_layout.dart @@ -3,10 +3,12 @@ import 'dart:async'; import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:models/models.dart'; import 'package:multichoice/app/export.dart'; import 'package:multichoice/i18n/localize_core_message.dart'; import 'package:multichoice/i18n/strings.g.dart'; import 'package:multichoice/presentation/home/home_page.dart'; +import 'package:multichoice/utils/app_tips/app_tip_showcase.dart'; import 'package:ui_kit/ui_kit.dart'; part 'widgets/home/horizontal_home.dart'; diff --git a/apps/multichoice/lib/layouts/home_layout/tab_layout.dart b/apps/multichoice/lib/layouts/home_layout/tab_layout.dart index e3b84e8f..2041d51b 100644 --- a/apps/multichoice/lib/layouts/home_layout/tab_layout.dart +++ b/apps/multichoice/lib/layouts/home_layout/tab_layout.dart @@ -6,6 +6,7 @@ import 'package:models/models.dart'; import 'package:multichoice/app/export.dart'; import 'package:multichoice/i18n/strings.g.dart'; import 'package:multichoice/presentation/home/home_page.dart'; +import 'package:multichoice/utils/app_tips/app_tip_showcase.dart'; import 'package:reorderable_grid/reorderable_grid.dart'; import 'package:ui_kit/ui_kit.dart'; diff --git a/apps/multichoice/lib/layouts/home_layout/widgets/home/horizontal_home.dart b/apps/multichoice/lib/layouts/home_layout/widgets/home/horizontal_home.dart index 2a700db4..0ac4b459 100644 --- a/apps/multichoice/lib/layouts/home_layout/widgets/home/horizontal_home.dart +++ b/apps/multichoice/lib/layouts/home_layout/widgets/home/horizontal_home.dart @@ -121,9 +121,13 @@ class _HorizontalHomeState extends State<_HorizontalHome> { return Padding( padding: vertical6, - child: CollectionTab( - tab: tab, - isEditMode: isEditMode, + child: AppTipShowcase( + tip: AppTip.collections, + enabled: index == 0, + child: CollectionTab( + tab: tab, + isEditMode: isEditMode, + ), ), ); }, @@ -132,7 +136,10 @@ class _HorizontalHomeState extends State<_HorizontalHome> { SliverPadding( padding: horizontal12 + bottom24, sliver: const SliverToBoxAdapter( - child: NewTab(), + child: AppTipShowcase( + tip: AppTip.addCollection, + child: NewTab(), + ), ), ), ], diff --git a/apps/multichoice/lib/layouts/home_layout/widgets/home/vertical_home.dart b/apps/multichoice/lib/layouts/home_layout/widgets/home/vertical_home.dart index b1b4f826..7a085cda 100644 --- a/apps/multichoice/lib/layouts/home_layout/widgets/home/vertical_home.dart +++ b/apps/multichoice/lib/layouts/home_layout/widgets/home/vertical_home.dart @@ -132,9 +132,13 @@ class _VerticalHomeState extends State<_VerticalHome> { return Padding( padding: horizontal6, - child: CollectionTab( - tab: tab, - isEditMode: isEditMode, + child: AppTipShowcase( + tip: AppTip.collections, + enabled: index == 0, + child: CollectionTab( + tab: tab, + isEditMode: isEditMode, + ), ), ); }, @@ -142,7 +146,10 @@ class _VerticalHomeState extends State<_VerticalHome> { const SliverPadding( padding: horizontal6, sliver: SliverToBoxAdapter( - child: NewTab(), + child: AppTipShowcase( + tip: AppTip.addCollection, + child: NewTab(), + ), ), ), ], diff --git a/apps/multichoice/lib/layouts/home_layout/widgets/tab/entries_grid.dart b/apps/multichoice/lib/layouts/home_layout/widgets/tab/entries_grid.dart index 65963899..50164a50 100644 --- a/apps/multichoice/lib/layouts/home_layout/widgets/tab/entries_grid.dart +++ b/apps/multichoice/lib/layouts/home_layout/widgets/tab/entries_grid.dart @@ -16,6 +16,10 @@ class EntriesGrid extends StatelessWidget { @override Widget build(BuildContext context) { + final isFirstTab = context.select( + (bloc) => bloc.state.tabs?.firstOrNull?.id == tabId, + ); + return SliverPadding( padding: vertical4, sliver: SliverGrid.builder( @@ -25,25 +29,33 @@ class EntriesGrid extends StatelessWidget { itemCount: entries.length + 1, itemBuilder: (context, index) { if (index == entries.length) { - return NewEntry( - tabId: tabId, + return AppTipShowcase( + tip: AppTip.addEntry, + enabled: isFirstTab, + child: NewEntry( + tabId: tabId, + ), ); } final entry = entries[index]; - return EntryCard( - entry: entry, - isLayoutVertical: isLayoutVertical, - isEditMode: isEditMode, - onDoubleTap: () async { - context.read().add( - HomeEvent.onUpdateEntry(entry.id), - ); - await context.router.push( - EditEntryPageRoute(ctx: context), - ); - }, + return AppTipShowcase( + tip: AppTip.entryActions, + enabled: isFirstTab && index == 0, + child: EntryCard( + entry: entry, + isLayoutVertical: isLayoutVertical, + isEditMode: isEditMode, + onDoubleTap: () async { + context.read().add( + HomeEvent.onUpdateEntry(entry.id), + ); + await context.router.push( + EditEntryPageRoute(ctx: context), + ); + }, + ), ); }, ), diff --git a/apps/multichoice/lib/presentation/drawer/home_drawer.dart b/apps/multichoice/lib/presentation/drawer/home_drawer.dart index bcfcd85a..ff2cb15a 100644 --- a/apps/multichoice/lib/presentation/drawer/home_drawer.dart +++ b/apps/multichoice/lib/presentation/drawer/home_drawer.dart @@ -8,6 +8,7 @@ import 'package:multichoice/app/view/debug/remote_config_debug_notifier.dart'; import 'package:multichoice/i18n/strings.g.dart'; import 'package:multichoice/presentation/drawer/widgets/export.dart'; import 'package:multichoice/presentation/registration/login_modal.dart'; +import 'package:multichoice/utils/app_tips/app_tip_drawer_showcase.dart'; import 'package:multichoice/utils/user_accounts_feature.dart'; import 'package:provider/provider.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -18,7 +19,12 @@ Future _drawerSessionLoggedIn() async { } class HomeDrawer extends StatelessWidget { - const HomeDrawer({super.key}); + const HomeDrawer({ + this.isDrawerOpen = true, + super.key, + }); + + final bool isDrawerOpen; void _onLogin(BuildContext context) { Navigator.of(context).pop(); @@ -75,7 +81,10 @@ class HomeDrawer extends StatelessWidget { physics: const BouncingScrollPhysics(), padding: EdgeInsets.zero, children: [ - const AppearanceSection(), + AppTipDrawerShowcase( + isDrawerOpen: isDrawerOpen, + child: const AppearanceSection(), + ), const Divider(height: 32), const DataSection(), if (isLoggedIn && userAccountsEnabled) ...[ diff --git a/apps/multichoice/lib/presentation/home/home_page.dart b/apps/multichoice/lib/presentation/home/home_page.dart index 55b8b475..3d722f3d 100644 --- a/apps/multichoice/lib/presentation/home/home_page.dart +++ b/apps/multichoice/lib/presentation/home/home_page.dart @@ -9,7 +9,6 @@ import 'package:multichoice/i18n/strings.g.dart'; import 'package:multichoice/layouts/export.dart'; import 'package:multichoice/presentation/drawer/home_drawer.dart'; import 'package:multichoice/presentation/home/utils/trigger_edit_mode_haptic.dart'; -import 'package:multichoice/presentation/home/widgets/app_tips_handler.dart'; import 'package:multichoice/presentation/home/widgets/home_app_bar.dart'; import 'package:multichoice/presentation/home/widgets/home_promotional_banners.dart'; import 'package:multichoice/presentation/home/widgets/update_modal_handler.dart'; @@ -17,6 +16,7 @@ import 'package:multichoice/presentation/home/widgets/welcome_modal_handler.dart import 'package:multichoice/presentation/shared/widgets/add_widgets/_base.dart'; import 'package:multichoice/presentation/shared/widgets/forms/reusable_form.dart'; import 'package:multichoice/presentation/shared/widgets/modals/delete_modal.dart'; +import 'package:multichoice/utils/app_tips/app_tip_coordinator.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:reorderable_grid/reorderable_grid.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -56,43 +56,50 @@ class _HomePage extends StatefulWidget { class _HomePageState extends State<_HomePage> { bool _isDrawerOpen = false; + final GlobalKey _tipCoordinatorKey = + GlobalKey(); @override Widget build(BuildContext context) { return AnalyticsPageTracker( page: AnalyticsPage.home, - child: BlocBuilder( - buildWhen: (previous, current) => - previous.isEditMode != current.isEditMode, - builder: (context, state) { - return PopScope( - canPop: !state.isEditMode && !_isDrawerOpen, - onPopInvokedWithResult: (didPop, _) { - if (didPop) return; + child: AppTipCoordinator( + key: _tipCoordinatorKey, + child: BlocBuilder( + buildWhen: (previous, current) => + previous.isEditMode != current.isEditMode, + builder: (context, state) { + return PopScope( + canPop: !state.isEditMode && !_isDrawerOpen, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; - if (_isDrawerOpen) { - Navigator.of(context).pop(); - return; - } + if (_isDrawerOpen) { + Navigator.of(context).pop(); + return; + } - if (state.isEditMode) { - context.read().add( - const HomeEvent.onToggleEditMode(), - ); - } - }, - child: Scaffold( - key: scaffoldKey, - onDrawerChanged: (isOpened) { - if (_isDrawerOpen == isOpened) return; - setState(() { - _isDrawerOpen = isOpened; - }); + if (state.isEditMode) { + context.read().add( + const HomeEvent.onToggleEditMode(), + ); + } }, - appBar: const HomeAppBar(), - drawer: const HomeDrawer(), - body: AppTipsHandler( - builder: (_) => const Column( + child: Scaffold( + key: scaffoldKey, + onDrawerChanged: (isOpened) { + if (_isDrawerOpen != isOpened) { + setState(() { + _isDrawerOpen = isOpened; + }); + } + _tipCoordinatorKey.currentState?.handleDrawerChanged( + isOpened: isOpened, + ); + }, + appBar: HomeAppBar(isDrawerOpen: _isDrawerOpen), + drawer: HomeDrawer(isDrawerOpen: _isDrawerOpen), + body: const Column( children: [ HomePromotionalBanners(), Expanded( @@ -103,9 +110,9 @@ class _HomePageState extends State<_HomePage> { ], ), ), - ), - ); - }, + ); + }, + ), ), ); } diff --git a/apps/multichoice/lib/presentation/home/widgets/app_tips_banner.dart b/apps/multichoice/lib/presentation/home/widgets/app_tips_banner.dart deleted file mode 100644 index 50c285f1..00000000 --- a/apps/multichoice/lib/presentation/home/widgets/app_tips_banner.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:models/models.dart'; -import 'package:multichoice/app/export.dart'; -import 'package:multichoice/i18n/strings.g.dart'; -import 'package:ui_kit/ui_kit.dart'; - -class AppTipsBanner extends StatelessWidget { - const AppTipsBanner({ - required this.tip, - required this.onDismiss, - super.key, - }); - - final AppTip tip; - final VoidCallback onDismiss; - - @override - Widget build(BuildContext context) { - final colors = context.theme.appColors; - final strings = _stringsForTip(context, tip); - - return DismissibleBannerBar( - variant: BannerBarVariant.pill, - title: strings.title, - body: Text( - strings.body, - style: context.theme.appTextTheme.bodyMedium, - ), - onDismiss: onDismiss, - backgroundColor: colors.primary!.withValues(alpha: 0.12), - dismissTooltip: context.t.common.dismiss, - leading: Icon( - Icons.lightbulb_outline, - color: colors.primary, - ), - ); - } - - ({String title, String body}) _stringsForTip( - BuildContext context, - AppTip tip, - ) { - final tips = context.t.tips; - return switch (tip) { - AppTip.collections => (title: tips.collectionsTitle, body: tips.collectionsBody), - AppTip.addCollection => (title: tips.addCollectionTitle, body: tips.addCollectionBody), - AppTip.addEntry => (title: tips.addEntryTitle, body: tips.addEntryBody), - AppTip.entryActions => (title: tips.entryActionsTitle, body: tips.entryActionsBody), - AppTip.editAndSearch => (title: tips.editAndSearchTitle, body: tips.editAndSearchBody), - AppTip.drawer => (title: tips.drawerTitle, body: tips.drawerBody), - }; - } -} diff --git a/apps/multichoice/lib/presentation/home/widgets/app_tips_handler.dart b/apps/multichoice/lib/presentation/home/widgets/app_tips_handler.dart deleted file mode 100644 index 67162b9e..00000000 --- a/apps/multichoice/lib/presentation/home/widgets/app_tips_handler.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:core/core.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:multichoice/presentation/home/widgets/app_tips_banner.dart'; - -class AppTipsHandler extends StatefulWidget { - const AppTipsHandler({ - required this.builder, - super.key, - }); - - final WidgetBuilder builder; - - @override - State createState() => _AppTipsHandlerState(); -} - -class _AppTipsHandlerState extends State { - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - context.read().add(const ProductEvent.init()); - }); - } - - @override - Widget build(BuildContext context) { - return BlocBuilder( - buildWhen: (previous, current) => previous.activeTip != current.activeTip, - builder: (context, state) { - final activeTip = state.activeTip; - - return Column( - children: [ - if (activeTip != null) - AppTipsBanner( - tip: activeTip, - onDismiss: () { - context.read().add( - ProductEvent.dismissTip(activeTip), - ); - }, - ), - Expanded(child: widget.builder(context)), - ], - ); - }, - ); - } -} diff --git a/apps/multichoice/lib/presentation/home/widgets/home_app_bar.dart b/apps/multichoice/lib/presentation/home/widgets/home_app_bar.dart index 36707f99..686a2de7 100644 --- a/apps/multichoice/lib/presentation/home/widgets/home_app_bar.dart +++ b/apps/multichoice/lib/presentation/home/widgets/home_app_bar.dart @@ -8,10 +8,16 @@ import 'package:multichoice/i18n/strings.g.dart'; import 'package:multichoice/presentation/home/widgets/edit_mode_button.dart'; import 'package:multichoice/presentation/home/widgets/profile_button.dart'; import 'package:multichoice/presentation/home/widgets/search_button.dart'; +import 'package:multichoice/utils/app_tips/app_tip_showcase.dart'; import 'package:ui_kit/ui_kit.dart'; class HomeAppBar extends StatelessWidget implements PreferredSizeWidget { - const HomeAppBar({super.key}); + const HomeAppBar({ + this.isDrawerOpen = false, + super.key, + }); + + final bool isDrawerOpen; @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); @@ -23,13 +29,21 @@ class HomeAppBar extends StatelessWidget implements PreferredSizeWidget { return AppBar( title: Text(context.t.appTitle), actions: [ - const EditModeButton(), AnimatedOpacity( opacity: state.isEditMode ? 0.35 : 1, duration: const Duration(milliseconds: 180), child: IgnorePointer( ignoring: state.isEditMode, - child: const SearchButton(), + child: AppTipShowcase( + tip: AppTip.editAndSearch, + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + EditModeButton(), + SearchButton(), + ], + ), + ), ), ), AnimatedOpacity( @@ -47,19 +61,23 @@ class HomeAppBar extends StatelessWidget implements PreferredSizeWidget { duration: const Duration(milliseconds: 180), child: IgnorePointer( ignoring: state.isEditMode, - child: IconButton( - onPressed: () async { - await coreSl().logEvent( - const UiActionEventData( - page: AnalyticsPage.home, - button: AnalyticsButton.settings, - action: AnalyticsAction.open, - ), - ); - scaffoldKey.currentState?.openDrawer(); - }, - tooltip: TooltipEnums.settings.label(context.t), - icon: const Icon(Icons.settings_outlined), + child: AppTipShowcase( + tip: AppTip.drawer, + enabled: !isDrawerOpen, + child: IconButton( + onPressed: () async { + await coreSl().logEvent( + const UiActionEventData( + page: AnalyticsPage.home, + button: AnalyticsButton.settings, + action: AnalyticsAction.open, + ), + ); + scaffoldKey.currentState?.openDrawer(); + }, + tooltip: TooltipEnums.settings.label(context.t), + icon: const Icon(Icons.settings_outlined), + ), ), ), ), diff --git a/apps/multichoice/lib/utils/app_tips/app_tip_content.dart b/apps/multichoice/lib/utils/app_tips/app_tip_content.dart new file mode 100644 index 00000000..7237fbc4 --- /dev/null +++ b/apps/multichoice/lib/utils/app_tips/app_tip_content.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:models/models.dart'; +import 'package:multichoice/i18n/strings.g.dart'; + +typedef AppTipStrings = ({String title, String body}); + +AppTipStrings appTipStrings(BuildContext context, AppTip tip) { + final tips = context.t.tips; + return switch (tip) { + AppTip.collections => ( + title: tips.collectionsTitle, + body: tips.collectionsBody, + ), + AppTip.addCollection => ( + title: tips.addCollectionTitle, + body: tips.addCollectionBody, + ), + AppTip.addEntry => ( + title: tips.addEntryTitle, + body: tips.addEntryBody, + ), + AppTip.entryActions => ( + title: tips.entryActionsTitle, + body: tips.entryActionsBody, + ), + AppTip.editAndSearch => ( + title: tips.editAndSearchTitle, + body: tips.editAndSearchBody, + ), + AppTip.drawer => ( + title: tips.drawerTitle, + body: tips.drawerBody, + ), + }; +} diff --git a/apps/multichoice/lib/utils/app_tips/app_tip_coordinator.dart b/apps/multichoice/lib/utils/app_tips/app_tip_coordinator.dart new file mode 100644 index 00000000..0d5ac33b --- /dev/null +++ b/apps/multichoice/lib/utils/app_tips/app_tip_coordinator.dart @@ -0,0 +1,93 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:models/models.dart'; +import 'package:multichoice/utils/app_tips/app_tip_keys.dart'; +import 'package:showcaseview/showcaseview.dart'; + +/// Hosts [ShowCaseWidget] and starts contextual tips without rebuilding the home body. +class AppTipCoordinator extends StatefulWidget { + const AppTipCoordinator({ + required this.child, + this.onDrawerOpened, + super.key, + }); + + final Widget child; + final ValueChanged? onDrawerOpened; + + @override + State createState() => AppTipCoordinatorState(); +} + +class AppTipCoordinatorState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + context.read().add(const ProductEvent.init()); + }); + } + + @override + Widget build(BuildContext context) { + // ShowCaseWidget is deprecated but still required until ShowcaseView.register() + // migration is planned. See showcaseview v5.0.0 changelog. + // ignore: deprecated_member_use + return ShowCaseWidget( + builder: (_) { + return BlocListener( + listenWhen: (previous, current) => + previous.activeTip != current.activeTip, + listener: (context, state) { + _handleActiveTipChanged(state.activeTip); + }, + child: widget.child, + ); + }, + ); + } + + void handleDrawerChanged({required bool isOpened}) { + widget.onDrawerOpened?.call(isOpened); + + if (!isOpened || !mounted) { + return; + } + + final activeTip = context.read().state.activeTip; + if (activeTip != AppTip.drawer) { + return; + } + + ShowcaseView.get().dismiss(); + _startShowcase([appTipKeys.drawerAppearance]); + } + + void _handleActiveTipChanged(AppTip? tip) { + if (tip == null) { + ShowcaseView.get().dismiss(); + return; + } + + if (tip == AppTip.drawer) { + _startShowcase([appTipKeys.drawerMenu]); + return; + } + + final key = appTipKeys.forTip(tip); + if (key == null) { + return; + } + + _startShowcase([key]); + } + + void _startShowcase(List keys) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ShowcaseView.get().startShowCase(keys); + }); + } +} diff --git a/apps/multichoice/lib/utils/app_tips/app_tip_drawer_showcase.dart b/apps/multichoice/lib/utils/app_tips/app_tip_drawer_showcase.dart new file mode 100644 index 00000000..da475620 --- /dev/null +++ b/apps/multichoice/lib/utils/app_tips/app_tip_drawer_showcase.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:models/models.dart'; +import 'package:multichoice/utils/app_tips/app_tip_keys.dart'; +import 'package:multichoice/utils/app_tips/app_tip_showcase.dart'; + +/// Drawer-only showcase target shown after the user opens the menu. +class AppTipDrawerShowcase extends StatelessWidget { + const AppTipDrawerShowcase({ + required this.isDrawerOpen, + required this.child, + super.key, + }); + + final bool isDrawerOpen; + final Widget child; + + @override + Widget build(BuildContext context) { + if (!isDrawerOpen) { + return child; + } + + return AppTipShowcase( + tip: AppTip.drawer, + showcaseKey: appTipKeys.drawerAppearance, + child: child, + ); + } +} diff --git a/apps/multichoice/lib/utils/app_tips/app_tip_keys.dart b/apps/multichoice/lib/utils/app_tips/app_tip_keys.dart new file mode 100644 index 00000000..61f17769 --- /dev/null +++ b/apps/multichoice/lib/utils/app_tips/app_tip_keys.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:models/models.dart'; + +/// Global keys for showcase targets on the home screen. +class AppTipKeys { + AppTipKeys._(); + + static final AppTipKeys instance = AppTipKeys._(); + + final GlobalKey> collections = GlobalKey(); + final GlobalKey> addCollection = GlobalKey(); + final GlobalKey> addEntry = GlobalKey(); + final GlobalKey> entryActions = GlobalKey(); + final GlobalKey> editAndSearch = GlobalKey(); + final GlobalKey> drawerMenu = GlobalKey(); + final GlobalKey> drawerAppearance = GlobalKey(); + + GlobalKey? forTip(AppTip tip) { + return switch (tip) { + AppTip.collections => collections, + AppTip.addCollection => addCollection, + AppTip.addEntry => addEntry, + AppTip.entryActions => entryActions, + AppTip.editAndSearch => editAndSearch, + AppTip.drawer => drawerMenu, + }; + } +} + +final AppTipKeys appTipKeys = AppTipKeys.instance; diff --git a/apps/multichoice/lib/utils/app_tips/app_tip_showcase.dart b/apps/multichoice/lib/utils/app_tips/app_tip_showcase.dart new file mode 100644 index 00000000..f86241c6 --- /dev/null +++ b/apps/multichoice/lib/utils/app_tips/app_tip_showcase.dart @@ -0,0 +1,64 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:models/models.dart'; +import 'package:multichoice/utils/app_tips/app_tip_content.dart'; +import 'package:multichoice/utils/app_tips/app_tip_keys.dart'; +import 'package:showcaseview/showcaseview.dart'; + +/// Anchors a contextual, dismissible showcase tooltip to [child] when [tip] is active. +class AppTipShowcase extends StatelessWidget { + const AppTipShowcase({ + required this.tip, + required this.child, + this.showcaseKey, + this.enabled = true, + super.key, + }); + + final AppTip tip; + final Widget child; + final GlobalKey? showcaseKey; + final bool enabled; + + @override + Widget build(BuildContext context) { + if (!enabled) { + return child; + } + + return BlocSelector( + selector: (state) => state.activeTip == tip, + builder: (context, isActive) { + if (!isActive) { + return child; + } + + final key = showcaseKey ?? appTipKeys.forTip(tip); + if (key == null) { + return child; + } + + final strings = appTipStrings(context, tip); + final isLightMode = Theme.of(context).brightness == Brightness.light; + + return Showcase( + key: key, + title: strings.title, + description: strings.body, + disableBarrierInteraction: false, + disposeOnTap: false, + overlayOpacity: isLightMode ? 0.65 : 0.35, + onTargetClick: () => _dismissTip(context, tip), + onToolTipClick: () => _dismissTip(context, tip), + child: child, + ); + }, + ); + } + + static void _dismissTip(BuildContext context, AppTip tip) { + ShowcaseView.get().dismiss(); + context.read().add(ProductEvent.dismissTip(tip)); + } +} diff --git a/apps/multichoice/pubspec.yaml b/apps/multichoice/pubspec.yaml index dbf28b12..d8444e8b 100644 --- a/apps/multichoice/pubspec.yaml +++ b/apps/multichoice/pubspec.yaml @@ -33,6 +33,7 @@ dependencies: provider: ^6.1.1 reorderable_grid: ^1.0.13 shared_preferences: ^2.2.2 + showcaseview: 5.0.1 slang: ^4.14.0 slang_flutter: ^4.14.0 super_clipboard: ^0.9.1 From 5d514c723fe0935d3b1558db90edddb75c03540a Mon Sep 17 00:00:00 2001 From: Zander Kotze Date: Mon, 15 Jun 2026 16:59:18 +0200 Subject: [PATCH 5/5] chore: Changes --- .../lib/presentation/drawer/home_drawer.dart | 12 +- .../lib/presentation/home/home_page.dart | 15 +-- .../home/widgets/home_app_bar.dart | 12 +- .../presentation/home/widgets/new_entry.dart | 117 ++++++++++-------- .../presentation/home/widgets/new_tab.dart | 95 +++++++------- .../utils/app_tips/app_tip_coordinator.dart | 16 +-- .../app_tips/app_tip_drawer_showcase.dart | 8 +- .../lib/utils/app_tips/app_tip_showcase.dart | 57 ++++----- 8 files changed, 167 insertions(+), 165 deletions(-) diff --git a/apps/multichoice/lib/presentation/drawer/home_drawer.dart b/apps/multichoice/lib/presentation/drawer/home_drawer.dart index ff2cb15a..44307cd5 100644 --- a/apps/multichoice/lib/presentation/drawer/home_drawer.dart +++ b/apps/multichoice/lib/presentation/drawer/home_drawer.dart @@ -19,12 +19,7 @@ Future _drawerSessionLoggedIn() async { } class HomeDrawer extends StatelessWidget { - const HomeDrawer({ - this.isDrawerOpen = true, - super.key, - }); - - final bool isDrawerOpen; + const HomeDrawer({super.key}); void _onLogin(BuildContext context) { Navigator.of(context).pop(); @@ -81,9 +76,8 @@ class HomeDrawer extends StatelessWidget { physics: const BouncingScrollPhysics(), padding: EdgeInsets.zero, children: [ - AppTipDrawerShowcase( - isDrawerOpen: isDrawerOpen, - child: const AppearanceSection(), + const AppTipDrawerShowcase( + child: AppearanceSection(), ), const Divider(height: 32), const DataSection(), diff --git a/apps/multichoice/lib/presentation/home/home_page.dart b/apps/multichoice/lib/presentation/home/home_page.dart index 3d722f3d..0bc4f934 100644 --- a/apps/multichoice/lib/presentation/home/home_page.dart +++ b/apps/multichoice/lib/presentation/home/home_page.dart @@ -55,7 +55,6 @@ class _HomePage extends StatefulWidget { } class _HomePageState extends State<_HomePage> { - bool _isDrawerOpen = false; final GlobalKey _tipCoordinatorKey = GlobalKey(); @@ -70,11 +69,12 @@ class _HomePageState extends State<_HomePage> { previous.isEditMode != current.isEditMode, builder: (context, state) { return PopScope( - canPop: !state.isEditMode && !_isDrawerOpen, + canPop: !state.isEditMode, onPopInvokedWithResult: (didPop, _) { if (didPop) return; - if (_isDrawerOpen) { + final isDrawerOpen = scaffoldKey.currentState?.isDrawerOpen; + if (isDrawerOpen ?? false) { Navigator.of(context).pop(); return; } @@ -88,17 +88,12 @@ class _HomePageState extends State<_HomePage> { child: Scaffold( key: scaffoldKey, onDrawerChanged: (isOpened) { - if (_isDrawerOpen != isOpened) { - setState(() { - _isDrawerOpen = isOpened; - }); - } _tipCoordinatorKey.currentState?.handleDrawerChanged( isOpened: isOpened, ); }, - appBar: HomeAppBar(isDrawerOpen: _isDrawerOpen), - drawer: HomeDrawer(isDrawerOpen: _isDrawerOpen), + appBar: const HomeAppBar(), + drawer: const HomeDrawer(), body: const Column( children: [ HomePromotionalBanners(), diff --git a/apps/multichoice/lib/presentation/home/widgets/home_app_bar.dart b/apps/multichoice/lib/presentation/home/widgets/home_app_bar.dart index 686a2de7..059856fe 100644 --- a/apps/multichoice/lib/presentation/home/widgets/home_app_bar.dart +++ b/apps/multichoice/lib/presentation/home/widgets/home_app_bar.dart @@ -12,12 +12,7 @@ import 'package:multichoice/utils/app_tips/app_tip_showcase.dart'; import 'package:ui_kit/ui_kit.dart'; class HomeAppBar extends StatelessWidget implements PreferredSizeWidget { - const HomeAppBar({ - this.isDrawerOpen = false, - super.key, - }); - - final bool isDrawerOpen; + const HomeAppBar({super.key}); @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); @@ -34,9 +29,9 @@ class HomeAppBar extends StatelessWidget implements PreferredSizeWidget { duration: const Duration(milliseconds: 180), child: IgnorePointer( ignoring: state.isEditMode, - child: AppTipShowcase( + child: const AppTipShowcase( tip: AppTip.editAndSearch, - child: const Row( + child: Row( mainAxisSize: MainAxisSize.min, children: [ EditModeButton(), @@ -63,7 +58,6 @@ class HomeAppBar extends StatelessWidget implements PreferredSizeWidget { ignoring: state.isEditMode, child: AppTipShowcase( tip: AppTip.drawer, - enabled: !isDrawerOpen, child: IconButton( onPressed: () async { await coreSl().logEvent( diff --git a/apps/multichoice/lib/presentation/home/widgets/new_entry.dart b/apps/multichoice/lib/presentation/home/widgets/new_entry.dart index 9d98cdea..8f2b068f 100644 --- a/apps/multichoice/lib/presentation/home/widgets/new_entry.dart +++ b/apps/multichoice/lib/presentation/home/widgets/new_entry.dart @@ -1,6 +1,6 @@ part of '../home_page.dart'; -class NewEntry extends StatefulWidget { +class NewEntry extends StatelessWidget { const NewEntry({ required this.tabId, super.key, @@ -9,10 +9,43 @@ class NewEntry extends StatefulWidget { final int tabId; @override - State createState() => _NewEntryState(); + Widget build(BuildContext context) { + return AddEntryCard( + key: context.keys.addNewEntryButton, + padding: zeroPadding, + onPressed: () { + final homeBloc = context.read(); + CustomDialog.show( + context: context, + title: Text( + key: context.keys.addNewEntryTitle, + context.t.home.addNewEntry, + style: DefaultTextStyle.of(context).style.copyWith( + fontSize: 24, + ), + ), + content: BlocProvider.value( + value: homeBloc, + child: _AddEntryDialogContent(tabId: tabId), + ), + ); + }, + ); + } } -class _NewEntryState extends State { +class _AddEntryDialogContent extends StatefulWidget { + const _AddEntryDialogContent({ + required this.tabId, + }); + + final int tabId; + + @override + State<_AddEntryDialogContent> createState() => _AddEntryDialogContentState(); +} + +class _AddEntryDialogContentState extends State<_AddEntryDialogContent> { late final TextEditingController _titleTextController; late final TextEditingController _subtitleTextController; @@ -30,11 +63,13 @@ class _NewEntryState extends State { super.dispose(); } - Future onPressed() async { + Future _closeDialog() async { Navigator.of(context).pop(); await Future.microtask(() { - _titleTextController.clear(); - _subtitleTextController.clear(); + if (mounted) { + _titleTextController.clear(); + _subtitleTextController.clear(); + } }); } @@ -42,55 +77,31 @@ class _NewEntryState extends State { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - final homeBloc = context.read(); - return AddEntryCard( - key: context.keys.addNewEntryButton, - padding: zeroPadding, - onPressed: () { - CustomDialog.show( - context: context, - title: Text( - key: context.keys.addNewEntryTitle, - context.t.home.addNewEntry, - style: DefaultTextStyle.of(context).style.copyWith( - fontSize: 24, - ), - ), - content: BlocProvider.value( - value: homeBloc, - child: BlocBuilder( - builder: (context, state) { - return ReusableForm( - titleController: _titleTextController, - subtitleController: _subtitleTextController, - onTitleChanged: (value) => context.read().add( - HomeEvent.onChangedEntryTitle(value), - ), - onTitleTap: () => context.read().add( - HomeEvent.onGetTab(widget.tabId), - ), - onSubtitleChanged: (value) => context - .read() - .add(HomeEvent.onChangedEntrySubtitle(value)), - onCancel: () async { - context.read().add( - const HomeEvent.onPressedCancel(), - ); - await onPressed(); - }, - onAdd: () async { - context.read().add( - const HomeEvent.onPressedAddEntry(), - ); - await onPressed(); - }, - isValid: state.isValid, - ); - }, - ), - ), + return ReusableForm( + titleController: _titleTextController, + subtitleController: _subtitleTextController, + onTitleChanged: (value) => context.read().add( + HomeEvent.onChangedEntryTitle(value), + ), + onTitleTap: () => context.read().add( + HomeEvent.onGetTab(widget.tabId), + ), + onSubtitleChanged: (value) => context.read().add( + HomeEvent.onChangedEntrySubtitle(value), + ), + onCancel: () async { + context.read().add( + const HomeEvent.onPressedCancel(), + ); + await _closeDialog(); + }, + onAdd: () async { + context.read().add( + const HomeEvent.onPressedAddEntry(), ); + await _closeDialog(); }, + isValid: state.isValid, ); }, ); diff --git a/apps/multichoice/lib/presentation/home/widgets/new_tab.dart b/apps/multichoice/lib/presentation/home/widgets/new_tab.dart index 1cd9de55..97f9e43f 100644 --- a/apps/multichoice/lib/presentation/home/widgets/new_tab.dart +++ b/apps/multichoice/lib/presentation/home/widgets/new_tab.dart @@ -1,13 +1,35 @@ part of '../home_page.dart'; -class NewTab extends StatefulWidget { +class NewTab extends StatelessWidget { const NewTab({super.key}); @override - State createState() => _NewTabState(); + Widget build(BuildContext context) { + return AddTabCard( + key: context.keys.addNewTabButton, + width: UIConstants.newTabWidth(context), + onPressed: () { + CustomDialog.show( + context: context, + title: Text(context.t.home.addNewTab), + content: BlocProvider.value( + value: context.read(), + child: const _AddTabDialogContent(), + ), + ); + }, + ); + } } -class _NewTabState extends State { +class _AddTabDialogContent extends StatefulWidget { + const _AddTabDialogContent(); + + @override + State<_AddTabDialogContent> createState() => _AddTabDialogContentState(); +} + +class _AddTabDialogContentState extends State<_AddTabDialogContent> { late final TextEditingController _titleTextController; late final TextEditingController _subtitleTextController; @@ -25,53 +47,42 @@ class _NewTabState extends State { super.dispose(); } - Future onPressed() async { + Future _closeDialog() async { Navigator.of(context).pop(); await Future.microtask(() { - _titleTextController.clear(); - _subtitleTextController.clear(); + if (mounted) { + _titleTextController.clear(); + _subtitleTextController.clear(); + } }); } @override Widget build(BuildContext context) { - return AddTabCard( - key: context.keys.addNewTabButton, - width: UIConstants.newTabWidth(context), - onPressed: () { - CustomDialog.show( - context: context, - title: Text(context.t.home.addNewTab), - content: BlocProvider.value( - value: context.read(), - child: BlocBuilder( - builder: (context, state) { - return ReusableForm( - titleController: _titleTextController, - subtitleController: _subtitleTextController, - onTitleChanged: (value) => context.read().add( - HomeEvent.onChangedTabTitle(value), - ), - onSubtitleChanged: (value) => context.read().add( - HomeEvent.onChangedTabSubtitle(value), - ), - onCancel: () async { - context.read().add( - const HomeEvent.onPressedCancel(), - ); - await onPressed(); - }, - onAdd: () async { - context.read().add( - const HomeEvent.onPressedAddTab(), - ); - await onPressed(); - }, - isValid: state.isValid, - ); - }, - ), + return BlocBuilder( + builder: (context, state) { + return ReusableForm( + titleController: _titleTextController, + subtitleController: _subtitleTextController, + onTitleChanged: (value) => context.read().add( + HomeEvent.onChangedTabTitle(value), + ), + onSubtitleChanged: (value) => context.read().add( + HomeEvent.onChangedTabSubtitle(value), ), + onCancel: () async { + context.read().add( + const HomeEvent.onPressedCancel(), + ); + await _closeDialog(); + }, + onAdd: () async { + context.read().add( + const HomeEvent.onPressedAddTab(), + ); + await _closeDialog(); + }, + isValid: state.isValid, ); }, ); diff --git a/apps/multichoice/lib/utils/app_tips/app_tip_coordinator.dart b/apps/multichoice/lib/utils/app_tips/app_tip_coordinator.dart index 0d5ac33b..7d521ae6 100644 --- a/apps/multichoice/lib/utils/app_tips/app_tip_coordinator.dart +++ b/apps/multichoice/lib/utils/app_tips/app_tip_coordinator.dart @@ -9,12 +9,10 @@ import 'package:showcaseview/showcaseview.dart'; class AppTipCoordinator extends StatefulWidget { const AppTipCoordinator({ required this.child, - this.onDrawerOpened, super.key, }); final Widget child; - final ValueChanged? onDrawerOpened; @override State createState() => AppTipCoordinatorState(); @@ -41,7 +39,10 @@ class AppTipCoordinatorState extends State { listenWhen: (previous, current) => previous.activeTip != current.activeTip, listener: (context, state) { - _handleActiveTipChanged(state.activeTip); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _handleActiveTipChanged(state.activeTip); + }); }, child: widget.child, ); @@ -50,8 +51,6 @@ class AppTipCoordinatorState extends State { } void handleDrawerChanged({required bool isOpened}) { - widget.onDrawerOpened?.call(isOpened); - if (!isOpened || !mounted) { return; } @@ -61,8 +60,11 @@ class AppTipCoordinatorState extends State { return; } - ShowcaseView.get().dismiss(); - _startShowcase([appTipKeys.drawerAppearance]); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ShowcaseView.get().dismiss(); + _startShowcase([appTipKeys.drawerAppearance]); + }); } void _handleActiveTipChanged(AppTip? tip) { diff --git a/apps/multichoice/lib/utils/app_tips/app_tip_drawer_showcase.dart b/apps/multichoice/lib/utils/app_tips/app_tip_drawer_showcase.dart index da475620..9b1c7b7e 100644 --- a/apps/multichoice/lib/utils/app_tips/app_tip_drawer_showcase.dart +++ b/apps/multichoice/lib/utils/app_tips/app_tip_drawer_showcase.dart @@ -3,23 +3,17 @@ import 'package:models/models.dart'; import 'package:multichoice/utils/app_tips/app_tip_keys.dart'; import 'package:multichoice/utils/app_tips/app_tip_showcase.dart'; -/// Drawer-only showcase target shown after the user opens the menu. +/// Drawer appearance section showcase target (started when the menu opens). class AppTipDrawerShowcase extends StatelessWidget { const AppTipDrawerShowcase({ - required this.isDrawerOpen, required this.child, super.key, }); - final bool isDrawerOpen; final Widget child; @override Widget build(BuildContext context) { - if (!isDrawerOpen) { - return child; - } - return AppTipShowcase( tip: AppTip.drawer, showcaseKey: appTipKeys.drawerAppearance, diff --git a/apps/multichoice/lib/utils/app_tips/app_tip_showcase.dart b/apps/multichoice/lib/utils/app_tips/app_tip_showcase.dart index f86241c6..85db8989 100644 --- a/apps/multichoice/lib/utils/app_tips/app_tip_showcase.dart +++ b/apps/multichoice/lib/utils/app_tips/app_tip_showcase.dart @@ -3,10 +3,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:models/models.dart'; import 'package:multichoice/utils/app_tips/app_tip_content.dart'; +import 'package:multichoice/utils/app_tips/app_tip_coordinator.dart' + show AppTipCoordinator; import 'package:multichoice/utils/app_tips/app_tip_keys.dart'; import 'package:showcaseview/showcaseview.dart'; -/// Anchors a contextual, dismissible showcase tooltip to [child] when [tip] is active. +/// Anchors a contextual, dismissible showcase tooltip to [child]. +/// +/// The [Showcase] stays in the tree so tip changes do not rebuild/dispose +/// descendants (e.g. add-tab dialogs with active [TextEditingController]s). +/// Visibility is driven by [AppTipCoordinator] via [ShowcaseView.startShowCase]. class AppTipShowcase extends StatelessWidget { const AppTipShowcase({ required this.tip, @@ -27,38 +33,33 @@ class AppTipShowcase extends StatelessWidget { return child; } - return BlocSelector( - selector: (state) => state.activeTip == tip, - builder: (context, isActive) { - if (!isActive) { - return child; - } - - final key = showcaseKey ?? appTipKeys.forTip(tip); - if (key == null) { - return child; - } + final key = showcaseKey ?? appTipKeys.forTip(tip); + if (key == null) { + return child; + } - final strings = appTipStrings(context, tip); - final isLightMode = Theme.of(context).brightness == Brightness.light; + final strings = appTipStrings(context, tip); + final isLightMode = Theme.of(context).brightness == Brightness.light; - return Showcase( - key: key, - title: strings.title, - description: strings.body, - disableBarrierInteraction: false, - disposeOnTap: false, - overlayOpacity: isLightMode ? 0.65 : 0.35, - onTargetClick: () => _dismissTip(context, tip), - onToolTipClick: () => _dismissTip(context, tip), - child: child, - ); - }, + return Showcase( + key: key, + title: strings.title, + description: strings.body, + disposeOnTap: false, + overlayOpacity: isLightMode ? 0.65 : 0.35, + onTargetClick: () => _dismissTip(context, tip), + onToolTipClick: () => _dismissTip(context, tip), + child: child, ); } static void _dismissTip(BuildContext context, AppTip tip) { - ShowcaseView.get().dismiss(); - context.read().add(ProductEvent.dismissTip(tip)); + final productBloc = context.read(); + WidgetsBinding.instance.addPostFrameCallback((_) { + ShowcaseView.get().dismiss(); + WidgetsBinding.instance.addPostFrameCallback((_) { + productBloc.add(ProductEvent.dismissTip(tip)); + }); + }); } }