diff --git a/lib/models/ib/ib_showcase.dart b/lib/models/ib/ib_showcase.dart new file mode 100644 index 00000000..e3995aa4 --- /dev/null +++ b/lib/models/ib/ib_showcase.dart @@ -0,0 +1,47 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +class IBShowCase { + bool nextButton, prevButton, tocButton, drawerButton; + + IBShowCase({ + @required this.nextButton, + @required this.prevButton, + @required this.tocButton, + @required this.drawerButton, + }); + + factory IBShowCase.fromJson(Map json) { + return IBShowCase( + nextButton: json['next'] ?? false, + prevButton: json['prev'] ?? false, + tocButton: json['toc'] ?? false, + drawerButton: json['drawer'] ?? false, + ); + } + + IBShowCase copyWith({ + bool nextButton, + bool prevButton, + bool tocButton, + bool drawerButton, + }) { + return IBShowCase( + nextButton: nextButton ?? this.nextButton, + prevButton: prevButton ?? this.prevButton, + tocButton: tocButton ?? this.tocButton, + drawerButton: drawerButton ?? this.drawerButton, + ); + } + + @override + String toString() { + return json.encode({ + 'next': nextButton, + 'prev': prevButton, + 'toc': tocButton, + 'drawer': drawerButton, + }); + } +} diff --git a/lib/services/local_storage_service.dart b/lib/services/local_storage_service.dart index 0064b604..8cc5f11e 100644 --- a/lib/services/local_storage_service.dart +++ b/lib/services/local_storage_service.dart @@ -14,6 +14,7 @@ class LocalStorageService { static const String IS_LOGGED_IN = 'is_logged_in'; static const String IS_FIRST_TIME_LOGIN = 'is_first_time_login'; static const String AUTH_TYPE = 'auth_type'; + static const String IB_SHOWCASE_STATE = 'ib_showcase_state'; static Future getInstance() async { _preferences ??= await SharedPreferences.getInstance(); @@ -86,4 +87,16 @@ class LocalStorageService { set authType(AuthType authType) { _saveToDisk(AUTH_TYPE, authTypeValues.reverse[authType]); } + + Map get getShowcaseState { + final Map result = + Map.castFrom( + json.decode(_getFromDisk(IB_SHOWCASE_STATE) ?? '{}') + as Map); + return result; + } + + set setShowcaseState(String state) { + _saveToDisk(IB_SHOWCASE_STATE, state); + } } diff --git a/lib/ui/views/ib/ib_landing_view.dart b/lib/ui/views/ib/ib_landing_view.dart index cbd7f780..19b358cf 100644 --- a/lib/ui/views/ib/ib_landing_view.dart +++ b/lib/ui/views/ib/ib_landing_view.dart @@ -7,6 +7,7 @@ import 'package:mobile_app/ui/components/cv_drawer_tile.dart'; import 'package:mobile_app/ui/views/base_view.dart'; import 'package:mobile_app/ui/views/ib/ib_page_view.dart'; import 'package:mobile_app/viewmodels/ib/ib_landing_viewmodel.dart'; +import 'package:showcaseview/showcaseview.dart'; import 'package:theme_provider/theme_provider.dart'; class IbLandingView extends StatefulWidget { @@ -26,6 +27,9 @@ class _IbLandingViewState extends State { ); IbChapter _selectedChapter; ValueNotifier _tocNotifier; + IbLandingViewModel _model; + + final GlobalKey _key = GlobalKey(); @override void initState() { @@ -49,6 +53,25 @@ class _IbLandingViewState extends State { Widget _buildAppBar() { return AppBar( + leading: IconButton( + onPressed: () { + if (!_model.showCaseState.drawerButton) { + _model.onShowCased('drawer'); + } + _key.currentState.openDrawer(); + }, + icon: Showcase( + key: _model.drawer, + description: 'Navigate to different chapters', + overlayPadding: const EdgeInsets.all(12.0), + onTargetClick: () { + _model.onShowCased('drawer'); + _key.currentState.openDrawer(); + }, + disposeOnTap: true, + child: const Icon(Icons.menu), + ), + ), title: Text( _selectedChapter.id == _homeChapter.id ? 'CircuitVerse' @@ -59,10 +82,21 @@ class _IbLandingViewState extends State { valueListenable: _tocNotifier, builder: (context, value, child) { return value != null - ? IconButton( - icon: const Icon(Icons.menu_book_rounded), - tooltip: 'Show Table of Contents', - onPressed: value, + ? Showcase( + key: _model.toc, + description: 'Show Table of Contents', + child: IconButton( + icon: const Icon(Icons.menu_book_rounded), + onPressed: value, + ), + onTargetClick: () { + _model.onShowCased('toc'); + if (_key.currentState.isDrawerOpen) Get.back(); + Future.delayed(const Duration(milliseconds: 200), () { + value(); + }); + }, + disposeOnTap: true, ) : Container(); }, @@ -135,7 +169,7 @@ class _IbLandingViewState extends State { return Column(children: _chapters); } - Widget _buildDrawer(IbLandingViewModel _model) { + Widget _buildDrawer() { return Drawer( child: Stack( children: [ @@ -194,7 +228,10 @@ class _IbLandingViewState extends State { @override Widget build(BuildContext context) { return BaseView( - onModelReady: (model) => model.fetchChapters(), + onModelReady: (model) { + _model = model; + model.init(); + }, builder: (context, model, child) { // Set next page for home page if (model.isSuccess(model.IB_FETCH_CHAPTERS) && @@ -209,41 +246,59 @@ class _IbLandingViewState extends State { setState(() => _selectedChapter = _homeChapter); return Future.value(false); } + _model.saveShowcaseState(); return Future.value(true); }, child: Theme( data: IbTheme.getThemeData(context), - child: Scaffold( - key: const Key('IbLandingScaffold'), - appBar: _buildAppBar(), - drawer: _buildDrawer(model), - body: PageTransitionSwitcher( - transitionBuilder: ( - Widget child, - Animation animation, - Animation secondaryAnimation, - ) { - return FadeThroughTransition( - animation: animation, - secondaryAnimation: secondaryAnimation, - child: child, - ); - }, - child: IbPageView( - key: Key(_selectedChapter.toString()), - tocCallback: (val) { - Future.delayed(Duration.zero, () async { - if (mounted) { - _tocNotifier.value = val; - } - }); - }, - setPage: (chapter) { - setState(() => _selectedChapter = chapter); - }, - chapter: _selectedChapter, - ), - ), + child: ShowCaseWidget( + onComplete: (index, globalKey) { + final String key = globalKey + .toString() + .substring(1, globalKey.toString().length - 1) + .split(" ") + .last; + model.onShowCased(key); + }, + builder: Builder(builder: (context) { + return Scaffold( + key: _key, + appBar: _buildAppBar(), + drawer: _buildDrawer(), + body: PageTransitionSwitcher( + transitionBuilder: ( + Widget child, + Animation animation, + Animation secondaryAnimation, + ) { + return FadeThroughTransition( + animation: animation, + secondaryAnimation: secondaryAnimation, + child: child, + ); + }, + child: IbPageView( + key: Key(_selectedChapter.toString()), + tocCallback: (val) { + Future.delayed(Duration.zero, () async { + if (mounted) { + _tocNotifier.value = val; + } + }); + }, + setPage: (chapter) { + setState(() => _selectedChapter = chapter); + }, + chapter: _selectedChapter, + setShowCase: (updatedState) { + model.showCaseState = updatedState; + }, + showCase: model.showCaseState, + globalKeysMap: model.keyMap, + ), + ), + ); + }), ), ), ); diff --git a/lib/ui/views/ib/ib_page_view.dart b/lib/ui/views/ib/ib_page_view.dart index d02d7612..a92b15ac 100644 --- a/lib/ui/views/ib/ib_page_view.dart +++ b/lib/ui/views/ib/ib_page_view.dart @@ -8,6 +8,7 @@ import 'package:mobile_app/ib_theme.dart'; import 'package:mobile_app/models/ib/ib_chapter.dart'; import 'package:mobile_app/models/ib/ib_content.dart'; import 'package:mobile_app/models/ib/ib_page_data.dart'; +import 'package:mobile_app/models/ib/ib_showcase.dart'; import 'package:mobile_app/services/ib_engine_service.dart'; import 'package:mobile_app/ui/views/base_view.dart'; import 'package:mobile_app/ui/views/ib/builders/ib_chapter_contents_builder.dart'; @@ -27,10 +28,12 @@ import 'package:mobile_app/ui/views/ib/syntaxes/ib_md_tag_syntax.dart'; import 'package:mobile_app/utils/url_launcher.dart'; import 'package:mobile_app/viewmodels/ib/ib_page_viewmodel.dart'; import 'package:scroll_to_index/scroll_to_index.dart'; +import 'package:showcaseview/showcaseview.dart'; import 'package:url_launcher/url_launcher.dart'; typedef TocCallback = void Function(Function); typedef SetPageCallback = void Function(IbChapter); +typedef SetShowCaseStateCallback = void Function(IBShowCase); class IbPageView extends StatefulWidget { const IbPageView({ @@ -38,12 +41,18 @@ class IbPageView extends StatefulWidget { @required this.tocCallback, @required this.chapter, @required this.setPage, + @required this.showCase, + @required this.setShowCase, + @required this.globalKeysMap, }) : super(key: key); static const String id = 'ib_page_view'; final TocCallback tocCallback; final SetPageCallback setPage; final IbChapter chapter; + final IBShowCase showCase; + final SetShowCaseStateCallback setShowCase; + final Map globalKeysMap; @override _IbPageViewState createState() => _IbPageViewState(); @@ -53,6 +62,7 @@ class _IbPageViewState extends State { IbPageViewModel _model; AutoScrollController _hideButtonController; bool _isFabsVisible = true; + ShowCaseWidgetState _showCaseWidgetState; /// To track index through slug for scroll_to_index final Map _slugMap = {}; @@ -60,6 +70,7 @@ class _IbPageViewState extends State { @override void initState() { super.initState(); + _showCaseWidgetState = ShowCaseWidget.of(context); _isFabsVisible = true; _hideButtonController = AutoScrollController(axis: Axis.vertical); _hideButtonController.addListener(() { @@ -74,6 +85,12 @@ class _IbPageViewState extends State { }); } + @override + void didChangeDependencies() { + _showCaseWidgetState = ShowCaseWidget.of(context); + super.didChangeDependencies(); + } + Widget _buildDivider() { return const Padding( padding: EdgeInsets.symmetric(vertical: 10), @@ -367,9 +384,20 @@ class _IbPageViewState extends State { } widget.setPage(widget.chapter.prev); }, - child: const Icon( - Icons.arrow_back_rounded, - color: IbTheme.primaryColor, + child: Showcase( + key: _model.prevPage, + description: 'Tap to navigate to previous page', + overlayPadding: const EdgeInsets.all(12.0), + shapeBorder: const CircleBorder(), + onTargetClick: () { + widget.setShowCase(widget.showCase.copyWith(prevButton: true)); + widget.setPage(widget.chapter.prev); + }, + disposeOnTap: true, + child: const Icon( + Icons.arrow_back_rounded, + color: IbTheme.primaryColor, + ), ), ), ), @@ -396,9 +424,20 @@ class _IbPageViewState extends State { } widget.setPage(widget.chapter.next); }, - child: const Icon( - Icons.arrow_forward_rounded, - color: IbTheme.primaryColor, + child: Showcase( + key: _model.nextPage, + description: 'Tap to navigate to next page', + overlayPadding: const EdgeInsets.all(12.0), + shapeBorder: const CircleBorder(), + onTargetClick: () { + widget.setShowCase(widget.showCase.copyWith(nextButton: true)); + widget.setPage(widget.chapter.next); + }, + disposeOnTap: false, + child: const Icon( + Icons.arrow_forward_rounded, + color: IbTheme.primaryColor, + ), ), ), ), @@ -454,6 +493,11 @@ class _IbPageViewState extends State { onModelReady: (model) { _model = model; model.fetchPageData(id: widget.chapter.id); + model.showCase( + _showCaseWidgetState, + widget.showCase, + widget.globalKeysMap, + ); }, builder: (context, model, child) { // Set the callback to show bottom sheet for Table of Contents diff --git a/lib/utils/router.dart b/lib/utils/router.dart index d036ebbe..f5b3eb8c 100644 --- a/lib/utils/router.dart +++ b/lib/utils/router.dart @@ -110,7 +110,9 @@ class CVRouter { ), ); case IbLandingView.id: - return MaterialPageRoute(builder: (_) => const IbLandingView()); + return MaterialPageRoute( + builder: (_) => const IbLandingView(), + ); case ProjectPreviewFullScreen.id: var _project = settings.arguments as Project; return MaterialPageRoute( diff --git a/lib/viewmodels/ib/ib_landing_viewmodel.dart b/lib/viewmodels/ib/ib_landing_viewmodel.dart index 6beb0a40..a9f873b2 100644 --- a/lib/viewmodels/ib/ib_landing_viewmodel.dart +++ b/lib/viewmodels/ib/ib_landing_viewmodel.dart @@ -1,8 +1,11 @@ +import 'package:flutter/material.dart'; import 'package:mobile_app/enums/view_state.dart'; import 'package:mobile_app/locator.dart'; import 'package:mobile_app/models/failure_model.dart'; import 'package:mobile_app/models/ib/ib_chapter.dart'; +import 'package:mobile_app/models/ib/ib_showcase.dart'; import 'package:mobile_app/services/ib_engine_service.dart'; +import 'package:mobile_app/services/local_storage_service.dart'; import 'package:mobile_app/viewmodels/base_viewmodel.dart'; class IbLandingViewModel extends BaseModel { @@ -10,11 +13,77 @@ class IbLandingViewModel extends BaseModel { String IB_FETCH_CHAPTERS = 'ib_fetch_chapters'; final IbEngineService _ibEngineService = locator(); + final LocalStorageService _localStorageService = + locator(); + + // Global Keys + final GlobalKey _toc = GlobalKey(debugLabel: 'toc'); + final GlobalKey _drawer = GlobalKey(debugLabel: 'drawer'); + + // Getter for Global Keys + GlobalKey get toc => _toc; + GlobalKey get drawer => _drawer; + + // Getter for Global Keys Map + Map get keyMap => { + 'toc': _toc, + 'drawer': _drawer, + }; List _chapters = []; List get chapters => _chapters; + // ShowCaseState stores the information of whether the button which is to be + // showcased are clicked or not + IBShowCase _showCaseState; + + IBShowCase get showCaseState => _showCaseState; + + set showCaseState(IBShowCase updatedState) { + _showCaseState = updatedState; + notifyListeners(); + } + + void onShowCased(String key) { + switch (key) { + case 'next': + if (!_showCaseState.nextButton) { + _showCaseState = _showCaseState.copyWith(nextButton: true); + saveShowcaseState(); + } + break; + case 'prev': + if (!_showCaseState.prevButton) { + _showCaseState = _showCaseState.copyWith(prevButton: true); + saveShowcaseState(); + } + break; + case 'toc': + if (!_showCaseState.tocButton) { + _showCaseState = _showCaseState.copyWith(tocButton: true); + saveShowcaseState(); + } + break; + case 'drawer': + if (!_showCaseState.drawerButton) { + _showCaseState = _showCaseState.copyWith(drawerButton: true); + saveShowcaseState(); + } + break; + } + notifyListeners(); + } + + void saveShowcaseState() { + _localStorageService.setShowcaseState = _showCaseState.toString(); + } + + void init() { + _showCaseState = IBShowCase.fromJson(_localStorageService.getShowcaseState); + fetchChapters(); + } + Future fetchChapters() async { try { _chapters = await _ibEngineService.getChapters(); diff --git a/lib/viewmodels/ib/ib_page_viewmodel.dart b/lib/viewmodels/ib/ib_page_viewmodel.dart index da231cf8..49ecb575 100644 --- a/lib/viewmodels/ib/ib_page_viewmodel.dart +++ b/lib/viewmodels/ib/ib_page_viewmodel.dart @@ -1,10 +1,13 @@ +import 'package:flutter/material.dart'; import 'package:mobile_app/enums/view_state.dart'; import 'package:mobile_app/locator.dart'; import 'package:mobile_app/models/failure_model.dart'; import 'package:mobile_app/models/ib/ib_page_data.dart'; import 'package:mobile_app/models/ib/ib_pop_quiz_question.dart'; +import 'package:mobile_app/models/ib/ib_showcase.dart'; import 'package:mobile_app/services/ib_engine_service.dart'; import 'package:mobile_app/viewmodels/base_viewmodel.dart'; +import 'package:showcaseview/showcaseview.dart'; class IbPageViewModel extends BaseModel { // ViewState Keys @@ -12,6 +15,17 @@ class IbPageViewModel extends BaseModel { String IB_FETCH_INTERACTION_DATA = 'ib_fetch_interaction_data'; String IB_FETCH_POP_QUIZ = 'ib_fetch_pop_quiz'; + // List of Global Keys to be Showcase + List _list; + + // Global Keys + final GlobalKey _nextPage = GlobalKey(debugLabel: 'next'); + final GlobalKey _prevPage = GlobalKey(debugLabel: 'prev'); + + // Getter for Global Keys + GlobalKey get nextPage => _nextPage; + GlobalKey get prevPage => _prevPage; + final IbEngineService _ibEngineService = locator(); IbPageData _pageData; @@ -37,6 +51,27 @@ class IbPageViewModel extends BaseModel { } } + void showCase( + ShowCaseWidgetState showCaseWidgetState, + IBShowCase state, + Map keysMap, + ) { + _list = []; + + if (!state.nextButton) _list.add(_nextPage); + if (!state.prevButton) _list.add(_prevPage); + if (!state.drawerButton) _list.add(keysMap['drawer']); + if (!state.tocButton) _list.add(keysMap['toc']); + + if (_list.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + Future.delayed(const Duration(milliseconds: 800), () { + showCaseWidgetState.startShowCase(_list); + }); + }); + } + } + List fetchPopQuiz(String rawContent) { try { var result = _ibEngineService.getPopQuiz(rawContent); diff --git a/pubspec.lock b/pubspec.lock index 18b181ea..989f42a5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -835,6 +835,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.1" + showcaseview: + dependency: "direct main" + description: + name: showcaseview + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.4" sky_engine: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index a9da8f5b..43afd125 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -57,6 +57,7 @@ dependencies: hive_flutter: ^1.1.0 flutter_math_fork: ^0.5.0 scroll_to_index: ^2.0.0 + showcaseview: ^1.1.4 dev_dependencies: flutter_test: diff --git a/test/ui_tests/ib/ib_landing_view_test.dart b/test/ui_tests/ib/ib_landing_view_test.dart index 6881fe1a..f183a653 100644 --- a/test/ui_tests/ib/ib_landing_view_test.dart +++ b/test/ui_tests/ib/ib_landing_view_test.dart @@ -10,6 +10,7 @@ import 'package:mobile_app/viewmodels/ib/ib_landing_viewmodel.dart'; import 'package:mobile_app/viewmodels/ib/ib_page_viewmodel.dart'; import 'package:mockito/mockito.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:showcaseview/showcaseview.dart'; import '../../setup/test_helpers.dart'; @@ -57,12 +58,20 @@ void main() { ], ) ]); + when(model.drawer).thenAnswer((_) => GlobalKey()); + when(model.toc).thenAnswer((_) => GlobalKey()); + when(pageViewModel.nextPage).thenAnswer((_) => GlobalKey()); + when(pageViewModel.prevPage).thenAnswer((_) => GlobalKey()); await tester.pumpWidget( GetMaterialApp( onGenerateRoute: CVRouter.generateRoute, navigatorObservers: [mockObserver], - home: const IbLandingView(), + home: ShowCaseWidget( + builder: Builder(builder: (context) { + return const IbLandingView(); + }), + ), ), ); diff --git a/test/ui_tests/ib/ib_page_view_test.dart b/test/ui_tests/ib/ib_page_view_test.dart index 40877bb8..3f69361a 100644 --- a/test/ui_tests/ib/ib_page_view_test.dart +++ b/test/ui_tests/ib/ib_page_view_test.dart @@ -5,11 +5,13 @@ import 'package:mobile_app/locator.dart'; import 'package:mobile_app/models/ib/ib_chapter.dart'; import 'package:mobile_app/models/ib/ib_content.dart'; import 'package:mobile_app/models/ib/ib_page_data.dart'; +import 'package:mobile_app/models/ib/ib_showcase.dart'; import 'package:mobile_app/ui/views/ib/ib_page_view.dart'; import 'package:mobile_app/utils/router.dart'; import 'package:mobile_app/viewmodels/ib/ib_page_viewmodel.dart'; import 'package:mockito/mockito.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:showcaseview/showcaseview.dart'; import '../../setup/test_data/mock_ib_raw_page_data.dart'; import '../../setup/test_helpers.dart'; @@ -31,6 +33,17 @@ void main() { var model = MockIbPageViewModel(); locator.registerSingleton(model); + // Mock ShowCase State + var showCase = IBShowCase( + nextButton: true, + prevButton: true, + tocButton: true, + drawerButton: true, + ); + + // Mock Global Key Map + const Map globalKeyMap = {}; + // Mock Page Data when(model.fetchPageData()).thenReturn(null); when(model.isSuccess(model.IB_FETCH_PAGE_DATA)).thenAnswer((_) => true); @@ -43,6 +56,8 @@ void main() { tableOfContents: [], ), ); + when(model.nextPage).thenAnswer((_) => GlobalKey()); + when(model.prevPage).thenAnswer((_) => GlobalKey()); // Mock Page Data var _chapter = IbChapter( @@ -58,11 +73,18 @@ void main() { GetMaterialApp( onGenerateRoute: CVRouter.generateRoute, navigatorObservers: [mockObserver], - home: IbPageView( - key: UniqueKey(), - chapter: _chapter, - tocCallback: (val) {}, - setPage: (e) {}, + home: ShowCaseWidget( + builder: Builder(builder: (context) { + return IbPageView( + key: UniqueKey(), + chapter: _chapter, + tocCallback: (val) {}, + setPage: (e) {}, + showCase: showCase, + setShowCase: (e) {}, + globalKeysMap: globalKeyMap, + ); + }), ), ), );