diff --git a/frontend/app_flowy/lib/startup/deps_resolver.dart b/frontend/app_flowy/lib/startup/deps_resolver.dart index 5896d559a3..dc4a1b6fc7 100644 --- a/frontend/app_flowy/lib/startup/deps_resolver.dart +++ b/frontend/app_flowy/lib/startup/deps_resolver.dart @@ -14,6 +14,7 @@ import 'package:app_flowy/workspace/application/menu/prelude.dart'; import 'package:app_flowy/user/application/prelude.dart'; import 'package:app_flowy/user/presentation/router.dart'; import 'package:app_flowy/workspace/presentation/home/home_stack.dart'; +import 'package:app_flowy/workspace/presentation/home/menu/menu.dart'; import 'package:flowy_sdk/protobuf/flowy-folder-data-model/app.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-folder-data-model/view.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/date_type_option.pb.dart'; @@ -50,6 +51,8 @@ void _resolveUserDeps(GetIt getIt) { } void _resolveHomeDeps(GetIt getIt) { + getIt.registerSingleton(MenuSharedState()); + getIt.registerFactoryParam( (user, _) => UserListener(user: user), ); @@ -113,8 +116,8 @@ void _resolveFolderDeps(GetIt getIt) { getIt.registerFactoryParam( (app, _) => AppBloc( app: app, - service: AppService(), - listener: AppListener(appId: app.id), + appService: AppService(), + appListener: AppListener(appId: app.id), ), ); diff --git a/frontend/app_flowy/lib/workspace/application/app/app_bloc.dart b/frontend/app_flowy/lib/workspace/application/app/app_bloc.dart index 73192436bf..c4b3c8e1ff 100644 --- a/frontend/app_flowy/lib/workspace/application/app/app_bloc.dart +++ b/frontend/app_flowy/lib/workspace/application/app/app_bloc.dart @@ -1,10 +1,14 @@ import 'package:app_flowy/plugin/plugin.dart'; +import 'package:app_flowy/startup/startup.dart'; import 'package:app_flowy/workspace/application/app/app_listener.dart'; import 'package:app_flowy/workspace/application/app/app_service.dart'; +import 'package:app_flowy/workspace/presentation/home/menu/menu.dart'; +import 'package:expandable/expandable.dart'; import 'package:flowy_sdk/log.dart'; import 'package:flowy_sdk/protobuf/flowy-folder-data-model/app.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-folder-data-model/view.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; +import 'package:flutter/foundation.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:dartz/dartz.dart'; @@ -13,71 +17,83 @@ part 'app_bloc.freezed.dart'; class AppBloc extends Bloc { final App app; - final AppService service; - final AppListener listener; + final AppService appService; + final AppListener appListener; - AppBloc({required this.app, required this.service, required this.listener}) : super(AppState.initial(app)) { + AppBloc({required this.app, required this.appService, required this.appListener}) : super(AppState.initial(app)) { on((event, emit) async { await event.map(initial: (e) async { - listener.start( - viewsChanged: _handleViewsChanged, - appUpdated: (app) => add(AppEvent.appDidUpdate(app)), - ); - await _fetchViews(emit); + _startListening(); + await _loadViews(emit); }, createView: (CreateView value) async { - final viewOrFailed = await service.createView( - appId: app.id, - name: value.name, - desc: value.desc, - dataType: value.dataType, - pluginType: value.pluginType, - ); - viewOrFailed.fold( - (view) => emit(state.copyWith( - latestCreatedView: view, - successOrFailure: left(unit), - )), - (error) { - Log.error(error); - emit(state.copyWith(successOrFailure: right(error))); - }, - ); - }, didReceiveViews: (e) async { - await handleDidReceiveViews(e.views, emit); + await _createView(value, emit); + }, didReceiveViewUpdated: (e) async { + await _didReceiveViewUpdated(e.views, emit); }, delete: (e) async { - final result = await service.delete(appId: app.id); - result.fold( - (unit) => emit(state.copyWith(successOrFailure: left(unit))), - (error) => emit(state.copyWith(successOrFailure: right(error))), - ); + await _deleteView(emit); }, rename: (e) async { - final result = await service.updateApp(appId: app.id, name: e.newName); - result.fold( - (l) => emit(state.copyWith(successOrFailure: left(unit))), - (error) => emit(state.copyWith(successOrFailure: right(error))), - ); + await _renameView(e, emit); }, appDidUpdate: (e) async { emit(state.copyWith(app: e.app)); }); }); } - @override - Future close() async { - await listener.close(); - return super.close(); + void _startListening() { + appListener.start( + viewsChanged: (result) { + result.fold( + (views) => add(AppEvent.didReceiveViewUpdated(views)), + (error) => Log.error(error), + ); + }, + appUpdated: (app) => add(AppEvent.appDidUpdate(app)), + ); } - void _handleViewsChanged(Either, FlowyError> result) { + Future _renameView(Rename e, Emitter emit) async { + final result = await appService.updateApp(appId: app.id, name: e.newName); result.fold( - (views) => add(AppEvent.didReceiveViews(views)), + (l) => emit(state.copyWith(successOrFailure: left(unit))), + (error) => emit(state.copyWith(successOrFailure: right(error))), + ); + } + + Future _deleteView(Emitter emit) async { + final result = await appService.delete(appId: app.id); + result.fold( + (unit) => emit(state.copyWith(successOrFailure: left(unit))), + (error) => emit(state.copyWith(successOrFailure: right(error))), + ); + } + + Future _createView(CreateView value, Emitter emit) async { + final viewOrFailed = await appService.createView( + appId: app.id, + name: value.name, + desc: value.desc, + dataType: value.dataType, + pluginType: value.pluginType, + ); + viewOrFailed.fold( + (view) => emit(state.copyWith( + latestCreatedView: view, + successOrFailure: left(unit), + )), (error) { Log.error(error); + emit(state.copyWith(successOrFailure: right(error))); }, ); } - Future handleDidReceiveViews(List views, Emitter emit) async { + @override + Future close() async { + await appListener.close(); + return super.close(); + } + + Future _didReceiveViewUpdated(List views, Emitter emit) async { final latestCreatedView = state.latestCreatedView; AppState newState = state.copyWith(views: views); if (latestCreatedView != null) { @@ -90,10 +106,10 @@ class AppBloc extends Bloc { emit(newState); } - Future _fetchViews(Emitter emit) async { - final viewsOrFailed = await service.getViews(appId: app.id); + Future _loadViews(Emitter emit) async { + final viewsOrFailed = await appService.getViews(appId: app.id); viewsOrFailed.fold( - (apps) => emit(state.copyWith(views: apps)), + (views) => emit(state.copyWith(views: views)), (error) { Log.error(error); emit(state.copyWith(successOrFailure: right(error))); @@ -113,7 +129,7 @@ class AppEvent with _$AppEvent { ) = CreateView; const factory AppEvent.delete() = Delete; const factory AppEvent.rename(String newName) = Rename; - const factory AppEvent.didReceiveViews(List views) = ReceiveViews; + const factory AppEvent.didReceiveViewUpdated(List views) = ReceiveViews; const factory AppEvent.appDidUpdate(App app) = AppDidUpdate; } @@ -121,17 +137,62 @@ class AppEvent with _$AppEvent { class AppState with _$AppState { const factory AppState({ required App app, - required bool isLoading, - required List? views, + required List views, View? latestCreatedView, required Either successOrFailure, }) = _AppState; factory AppState.initial(App app) => AppState( app: app, - isLoading: false, - views: null, - latestCreatedView: null, + views: [], successOrFailure: left(unit), ); } + +class AppViewDataNotifier extends ChangeNotifier { + List _views = []; + View? _selectedView; + ExpandableController expandController = ExpandableController(initialExpanded: false); + + AppViewDataNotifier() { + _setLatestView(getIt().latestOpenView); + getIt().addLatestViewListener((view) { + _setLatestView(view); + }); + } + + void _setLatestView(View? view) { + view?.freeze(); + _selectedView = view; + _expandIfNeed(); + } + + View? get selectedView => _selectedView; + + set views(List views) { + if (_views != views) { + _views = views; + _expandIfNeed(); + notifyListeners(); + } + } + + void _expandIfNeed() { + if (_selectedView == null) { + return; + } + + if (!_views.contains(_selectedView!)) { + return; + } + + if (expandController.expanded == false) { + // Workaround: Delay 150 milliseconds to make the smooth animation while expanding + Future.delayed(const Duration(milliseconds: 150), () { + expandController.expanded = true; + }); + } + } + + UnmodifiableListView get views => UnmodifiableListView(_views); +} diff --git a/frontend/app_flowy/lib/workspace/presentation/home/home_screen.dart b/frontend/app_flowy/lib/workspace/presentation/home/home_screen.dart index e9bb80dd30..cea29792cb 100644 --- a/frontend/app_flowy/lib/workspace/presentation/home/home_screen.dart +++ b/frontend/app_flowy/lib/workspace/presentation/home/home_screen.dart @@ -121,6 +121,9 @@ class _HomeScreenState extends State { collapsedNotifier: getIt().collapsedNotifier, ); + final latestView = widget.workspaceSetting.hasLatestView() ? widget.workspaceSetting.latestView : null; + getIt().latestOpenView = latestView; + return FocusTraversalGroup(child: RepaintBoundary(child: homeMenu)); } diff --git a/frontend/app_flowy/lib/workspace/presentation/home/menu/app/menu_app.dart b/frontend/app_flowy/lib/workspace/presentation/home/menu/app/menu_app.dart index d028355ed1..a7167a1997 100644 --- a/frontend/app_flowy/lib/workspace/presentation/home/menu/app/menu_app.dart +++ b/frontend/app_flowy/lib/workspace/presentation/home/menu/app/menu_app.dart @@ -2,7 +2,6 @@ import 'package:app_flowy/workspace/application/appearance.dart'; import 'package:app_flowy/workspace/presentation/home/menu/menu.dart'; import 'package:expandable/expandable.dart'; import 'package:flowy_sdk/protobuf/flowy-folder-data-model/app.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder-data-model/view.pb.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:app_flowy/startup/startup.dart'; @@ -19,11 +18,11 @@ class MenuApp extends StatefulWidget { } class _MenuAppState extends State { - late AppDataNotifier notifier; + late AppViewDataNotifier notifier; @override void initState() { - notifier = AppDataNotifier(); + notifier = AppViewDataNotifier(); super.initState(); } @@ -39,30 +38,34 @@ class _MenuAppState extends State { }, ), ], - child: BlocSelector( - selector: (state) { - final menuSharedState = Provider.of(context, listen: false); - if (state.latestCreatedView != null) { - menuSharedState.forcedOpenView.value = state.latestCreatedView!; - } - - notifier.views = state.views; - notifier.selectedView = menuSharedState.selectedView.value; - return notifier; - }, - builder: (context, notifier) => ChangeNotifierProvider.value( - value: notifier, - child: Consumer( - builder: (BuildContext context, AppDataNotifier notifier, Widget? child) { - return expandableWrapper(context, notifier); - }, + child: MultiBlocListener( + listeners: [ + BlocListener( + listenWhen: (p, c) => p.latestCreatedView != c.latestCreatedView, + listener: (context, state) => getIt().latestOpenView = state.latestCreatedView, ), + BlocListener( + listenWhen: (p, c) => p.views != c.views, + listener: (context, state) => notifier.views = state.views, + ), + ], + child: BlocBuilder( + builder: (context, state) { + return ChangeNotifierProvider.value( + value: notifier, + child: Consumer( + builder: (context, notifier, _) { + return expandableWrapper(context, notifier); + }, + ), + ); + }, ), ), ); } - ExpandableNotifier expandableWrapper(BuildContext context, AppDataNotifier notifier) { + ExpandableNotifier expandableWrapper(BuildContext context, AppViewDataNotifier notifier) { return ExpandableNotifier( controller: notifier.expandController, child: ScrollOnExpand( @@ -92,11 +95,11 @@ class _MenuAppState extends State { ); } - Widget _renderViewSection(AppDataNotifier notifier) { + Widget _renderViewSection(AppViewDataNotifier notifier) { return MultiProvider( providers: [ChangeNotifierProvider.value(value: notifier)], child: Consumer( - builder: (context, AppDataNotifier notifier, child) { + builder: (context, AppViewDataNotifier notifier, child) { return ViewSection(appData: notifier); }, ), @@ -119,44 +122,3 @@ class MenuAppSizes { static double scale = 1; static double get expandedPadding => iconSize * scale + headerPadding; } - -class AppDataNotifier extends ChangeNotifier { - List _views = []; - View? _selectedView; - ExpandableController expandController = ExpandableController(initialExpanded: false); - - AppDataNotifier(); - - set selectedView(View? view) { - _selectedView = view; - - if (view != null && _views.isNotEmpty) { - final isExpanded = _views.contains(view); - if (expandController.expanded == false && expandController.expanded != isExpanded) { - // Workaround: Delay 150 milliseconds to make the smooth animation while expanding - Future.delayed(const Duration(milliseconds: 150), () { - expandController.expanded = isExpanded; - }); - } - } - } - - View? get selectedView => _selectedView; - - set views(List? views) { - if (views == null) { - if (_views.isNotEmpty) { - _views = List.empty(growable: false); - notifyListeners(); - } - return; - } - - if (_views != views) { - _views = views; - notifyListeners(); - } - } - - List get views => _views; -} diff --git a/frontend/app_flowy/lib/workspace/presentation/home/menu/app/section/section.dart b/frontend/app_flowy/lib/workspace/presentation/home/menu/app/section/section.dart index 59324fa0a7..3eb28aef1e 100644 --- a/frontend/app_flowy/lib/workspace/presentation/home/menu/app/section/section.dart +++ b/frontend/app_flowy/lib/workspace/presentation/home/menu/app/section/section.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:developer'; import 'package:app_flowy/startup/startup.dart'; +import 'package:app_flowy/workspace/application/app/app_bloc.dart'; import 'package:app_flowy/workspace/application/view/view_ext.dart'; import 'package:app_flowy/workspace/presentation/home/home_stack.dart'; import 'package:app_flowy/workspace/presentation/home/menu/menu.dart'; @@ -13,13 +14,13 @@ import 'package:styled_widget/styled_widget.dart'; import 'item.dart'; class ViewSection extends StatelessWidget { - final AppDataNotifier appData; + final AppViewDataNotifier appData; const ViewSection({Key? key, required this.appData}) : super(key: key); @override Widget build(BuildContext context) { // The ViewSectionNotifier will be updated after AppDataNotifier changed passed by parent widget - return ChangeNotifierProxyProvider( + return ChangeNotifierProxyProvider( create: (_) { return ViewSectionNotifier( context: context, @@ -29,7 +30,7 @@ class ViewSection extends StatelessWidget { }, update: (_, notifier, controller) => controller!..update(notifier), child: Consumer(builder: (context, ViewSectionNotifier notifier, child) { - return RenderSectionItems(views: notifier.views); + return _SectionItems(views: notifier.views); }), ); } @@ -63,16 +64,16 @@ class ViewSection extends StatelessWidget { // } } -class RenderSectionItems extends StatefulWidget { - const RenderSectionItems({Key? key, required this.views}) : super(key: key); +class _SectionItems extends StatefulWidget { + const _SectionItems({Key? key, required this.views}) : super(key: key); final List views; @override - State createState() => _RenderSectionItemsState(); + State<_SectionItems> createState() => _SectionItemsState(); } -class _RenderSectionItemsState extends State { +class _SectionItemsState extends State<_SectionItems> { List views = []; /// Maps the hasmap value of the section items to their index in the reorderable list. @@ -123,10 +124,7 @@ class _RenderSectionItemsState extends State { (view) => ViewSectionItem( view: view, isSelected: _isViewSelected(context, view.id), - onSelected: (view) { - context.read().selectedView = view; - Provider.of(context, listen: false).selectedView.value = view; - }, + onSelected: (view) => getIt().latestOpenView = view, ).padding(vertical: 4), ) .toList()[index], @@ -150,6 +148,7 @@ class ViewSectionNotifier with ChangeNotifier { List _views; View? _selectedView; Timer? _notifyListenerOperation; + VoidCallback? _latestViewDidChangeFn; ViewSectionNotifier({ required BuildContext context, @@ -157,16 +156,10 @@ class ViewSectionNotifier with ChangeNotifier { View? initialSelectedView, }) : _views = views, _selectedView = initialSelectedView { - final menuSharedState = Provider.of(context, listen: false); - // The forcedOpenView will be the view after creating the new view - menuSharedState.forcedOpenView.addPublishListener((forcedOpenView) { - selectedView = forcedOpenView; - }); - - menuSharedState.selectedView.addListener(() { - // Cancel the selected view of this section by setting the selectedView to null - // that will notify the listener to refresh the ViewSection UI - if (menuSharedState.selectedView.value != _selectedView) { + _latestViewDidChangeFn = getIt().addLatestViewListener((latestOpenView) { + if (_views.contains(latestOpenView)) { + selectedView = latestOpenView; + } else { selectedView = null; } }); @@ -199,7 +192,7 @@ class ViewSectionNotifier with ChangeNotifier { View? get selectedView => _selectedView; - void update(AppDataNotifier notifier) { + void update(AppViewDataNotifier notifier) { views = notifier.views; } @@ -216,6 +209,10 @@ class ViewSectionNotifier with ChangeNotifier { void dispose() { isDisposed = true; _notifyListenerOperation?.cancel(); + if (_latestViewDidChangeFn != null) { + getIt().removeLatestViewListener(_latestViewDidChangeFn!); + _latestViewDidChangeFn = null; + } super.dispose(); } } diff --git a/frontend/app_flowy/lib/workspace/presentation/home/menu/menu.dart b/frontend/app_flowy/lib/workspace/presentation/home/menu/menu.dart index 17d7326f0a..f353f533de 100644 --- a/frontend/app_flowy/lib/workspace/presentation/home/menu/menu.dart +++ b/frontend/app_flowy/lib/workspace/presentation/home/menu/menu.dart @@ -88,30 +88,24 @@ class _HomeMenuState extends State { final theme = context.watch(); return Container( color: theme.bg1, - child: ChangeNotifierProvider( - create: (_) => - MenuSharedState(view: widget.workspaceSetting.hasLatestView() ? widget.workspaceSetting.latestView : null), - child: Consumer(builder: (context, MenuSharedState sharedState, child) { - return Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - const MenuTopBar(), - const VSpace(10), - _renderApps(context), - ], - ).padding(horizontal: Insets.l), - ), - const VSpace(20), - _renderTrash(context).padding(horizontal: Insets.l), - const VSpace(20), - _renderNewAppButton(context), - ], - ); - }), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + const MenuTopBar(), + const VSpace(10), + _renderApps(context), + ], + ).padding(horizontal: Insets.l), + ), + const VSpace(20), + _renderTrash(context).padding(horizontal: Insets.l), + const VSpace(20), + _renderNewAppButton(context), + ], ), ); } @@ -201,18 +195,32 @@ class _HomeMenuState extends State { } } -class MenuSharedState extends ChangeNotifier { - PublishNotifier forcedOpenView = PublishNotifier(); - ValueNotifier selectedView = ValueNotifier(null); +class MenuSharedState { + final ValueNotifier _latestOpenView = ValueNotifier(null); MenuSharedState({View? view}) { if (view != null) { - selectedView.value = view; + _latestOpenView.value = view; + } + } + + View? get latestOpenView => _latestOpenView.value; + + set latestOpenView(View? view) { + _latestOpenView.value = view; + } + + VoidCallback addLatestViewListener(void Function(View?) latestViewDidChange) { + onChanged() { + latestViewDidChange(_latestOpenView.value); } - forcedOpenView.addPublishListener((view) { - selectedView.value = view; - }); + _latestOpenView.addListener(onChanged); + return onChanged; + } + + void removeLatestViewListener(VoidCallback fn) { + _latestOpenView.removeListener(fn); } } diff --git a/frontend/app_flowy/lib/workspace/presentation/plugins/trash/menu.dart b/frontend/app_flowy/lib/workspace/presentation/plugins/trash/menu.dart index 770889c610..ec782db205 100644 --- a/frontend/app_flowy/lib/workspace/presentation/plugins/trash/menu.dart +++ b/frontend/app_flowy/lib/workspace/presentation/plugins/trash/menu.dart @@ -21,7 +21,7 @@ class MenuTrash extends StatelessWidget { height: 26, child: InkWell( onTap: () { - Provider.of(context, listen: false).selectedView.value = null; + getIt().latestOpenView = null; getIt().setPlugin(makePlugin(pluginType: DefaultPlugin.trash.type())); }, child: _render(context),