diff --git a/frontend/appflowy_flutter/lib/core/config/kv_keys.dart b/frontend/appflowy_flutter/lib/core/config/kv_keys.dart index e4be228133..94103ffef8 100644 --- a/frontend/appflowy_flutter/lib/core/config/kv_keys.dart +++ b/frontend/appflowy_flutter/lib/core/config/kv_keys.dart @@ -58,4 +58,10 @@ class KVKeys { /// The value range is from 0.8 to 1.0. If it's greater than 1.0, it will cause /// the text to be too large and not aligned with the icon static const String textScaleFactor = 'textScaleFactor'; + + /// The key for saving the feature flags + /// + /// The value is a json string with the following format: + /// {'feature_flag_1': true, 'feature_flag_2': false} + static const String featureFlag = 'featureFlag'; } diff --git a/frontend/appflowy_flutter/lib/shared/appflowy_cache_manager.dart b/frontend/appflowy_flutter/lib/shared/appflowy_cache_manager.dart index be62cc44be..4036d37b77 100644 --- a/frontend/appflowy_flutter/lib/shared/appflowy_cache_manager.dart +++ b/frontend/appflowy_flutter/lib/shared/appflowy_cache_manager.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy_backend/log.dart'; import 'package:path_provider/path_provider.dart'; @@ -59,3 +60,15 @@ class TemporaryDirectoryCache implements ICache { await tmpDir.delete(recursive: true); } } + +class FeatureFlagCache implements ICache { + @override + Future cacheSize() async { + return 0; + } + + @override + Future clearAll() async { + await FeatureFlag.clear(); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/feature_flags.dart b/frontend/appflowy_flutter/lib/shared/feature_flags.dart index 8e94c46d61..83a2a341a0 100644 --- a/frontend/appflowy_flutter/lib/shared/feature_flags.dart +++ b/frontend/appflowy_flutter/lib/shared/feature_flags.dart @@ -1,10 +1,17 @@ +import 'dart:collection'; +import 'dart:convert'; + +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/startup/startup.dart'; + +typedef FeatureFlagMap = Map; + /// The [FeatureFlag] is used to control the front-end features of the app. /// /// For example, if your feature is still under development, /// you can set the value to `false` to hide the feature. enum FeatureFlag { - // Feature flags - // used to control the visibility of the collaborative workspace feature // if it's on, you can see the workspace list and the workspace settings // in the top-left corner of the app @@ -14,7 +21,56 @@ enum FeatureFlag { // if it's on, you can see the members settings in the settings page membersSettings; + static Future initialize() async { + final values = await getIt().getWithFormat( + KVKeys.featureFlag, + (value) => Map.from(jsonDecode(value)).map( + (key, value) => MapEntry( + FeatureFlag.values.firstWhere((e) => e.name == key), + value as bool, + ), + ), + ) ?? + {}; + + _values = { + ...{for (final flag in FeatureFlag.values) flag: false}, + ...values, + }; + } + + static UnmodifiableMapView get data => + UnmodifiableMapView(_values); + + Future turnOn() async { + await update(true); + } + + Future turnOff() async { + await update(false); + } + + Future update(bool value) async { + _values[this] = value; + + await getIt().set( + KVKeys.featureFlag, + jsonEncode( + _values.map((key, value) => MapEntry(key.name, value)), + ), + ); + } + + static Future clear() async { + _values = {}; + await getIt().remove(KVKeys.featureFlag); + } + bool get isOn { + if (_values.containsKey(this)) { + return _values[this]!; + } + switch (this) { case FeatureFlag.collaborativeWorkspace: return false; @@ -22,4 +78,17 @@ enum FeatureFlag { return false; } } + + String get description { + switch (this) { + case FeatureFlag.collaborativeWorkspace: + return 'if it\'s on, you can see the workspace list and the workspace settings in the top-left corner of the app'; + case FeatureFlag.membersSettings: + return 'if it\'s on, you can see the members settings in the settings page'; + } + } + + String get key => 'appflowy_feature_flag_${toString()}'; } + +FeatureFlagMap _values = {}; diff --git a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart index d9e1692dd7..c2759ab2c8 100644 --- a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart +++ b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart @@ -134,7 +134,8 @@ void _resolveCommonService( getIt.registerFactory( () => FlowyCacheManager() ..registerCache(TemporaryDirectoryCache()) - ..registerCache(CustomImageCacheManager()), + ..registerCache(CustomImageCacheManager()) + ..registerCache(FeatureFlagCache()), ); } diff --git a/frontend/appflowy_flutter/lib/startup/startup.dart b/frontend/appflowy_flutter/lib/startup/startup.dart index 4b5c973468..38a4911da8 100644 --- a/frontend/appflowy_flutter/lib/startup/startup.dart +++ b/frontend/appflowy_flutter/lib/startup/startup.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/startup/tasks/feature_flag_task.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:appflowy_backend/appflowy_backend.dart'; import 'package:flutter/foundation.dart'; @@ -113,6 +114,7 @@ class FlowyRunner { // there's a flag named _enable in memory_leak_detector.dart. If it's false, the task will be ignored. MemoryLeakDetectorTask(), const DebugTask(), + const FeatureFlagTask(), // localization const InitLocalizationTask(), diff --git a/frontend/appflowy_flutter/lib/startup/tasks/feature_flag_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/feature_flag_task.dart new file mode 100644 index 0000000000..fd2439c892 --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/feature_flag_task.dart @@ -0,0 +1,21 @@ +import 'package:appflowy/shared/feature_flags.dart'; +import 'package:flutter/foundation.dart'; + +import '../startup.dart'; + +class FeatureFlagTask extends LaunchTask { + const FeatureFlagTask(); + + @override + Future initialize(LaunchContext context) async { + // the hotkey manager is not supported on mobile + if (!kDebugMode) { + return; + } + + await FeatureFlag.initialize(); + } + + @override + Future dispose() async {} +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart index 8f40d706f6..2d73d6ebe7 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart @@ -17,6 +17,7 @@ enum SettingsPage { cloud, shortcuts, member, + featureFlags, } class SettingsDialogBloc diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart index c24a6a9fab..484212a011 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart @@ -1,6 +1,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/feature_flags/feature_flag_page.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_page.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance_view.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart'; @@ -114,6 +115,8 @@ class SettingsDialog extends StatelessWidget { return const SettingsCustomizeShortcutsWrapper(); case SettingsPage.member: return WorkspaceMembersPage(userProfile: user); + case SettingsPage.featureFlags: + return const FeatureFlagsPage(); default: return const SizedBox.shrink(); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/feature_flags/feature_flag_page.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/feature_flags/feature_flag_page.dart new file mode 100644 index 0000000000..da5b16a7fa --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/feature_flags/feature_flag_page.dart @@ -0,0 +1,70 @@ +import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class FeatureFlagsPage extends StatelessWidget { + const FeatureFlagsPage({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: SeparatedColumn( + children: [ + ...FeatureFlag.data.entries.map( + (e) => _FeatureFlagItem(featureFlag: e.key), + ), + FlowyTextButton( + 'Restart the app to apply changes', + fontSize: 16.0, + fontColor: Colors.red, + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 12.0, + ), + onPressed: () async { + await runAppFlowy(); + }, + ), + ], + ), + ); + } +} + +class _FeatureFlagItem extends StatefulWidget { + const _FeatureFlagItem({ + required this.featureFlag, + }); + + final FeatureFlag featureFlag; + + @override + State<_FeatureFlagItem> createState() => _FeatureFlagItemState(); +} + +class _FeatureFlagItemState extends State<_FeatureFlagItem> { + @override + Widget build(BuildContext context) { + return ListTile( + title: FlowyText( + widget.featureFlag.name, + fontSize: 16.0, + ), + subtitle: FlowyText.small( + widget.featureFlag.description, + maxLines: 3, + ), + trailing: Switch( + value: widget.featureFlag.isOn, + onChanged: (value) { + setState(() { + widget.featureFlag.update(value); + }); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart index 278194bb4d..f9ae9b3124 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart @@ -4,6 +4,7 @@ import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dar import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu_element.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; class SettingsMenu extends StatelessWidget { @@ -79,6 +80,15 @@ class SettingsMenu extends StatelessWidget { icon: Icons.people, changeSelectedPage: changeSelectedPage, ), + if (kDebugMode) + SettingsMenuElement( + // no need to translate this page + page: SettingsPage.featureFlags, + selectedPage: currentPage, + label: 'Feature Flags', + icon: Icons.flag, + changeSelectedPage: changeSelectedPage, + ), ], ), ); diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/separated_flex.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/separated_flex.dart index 9b2cac25e4..c59c15e73a 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/separated_flex.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/separated_flex.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; typedef SeparatorBuilder = Widget Function(); +Widget _defaultColumnSeparatorBuilder() => const Divider(); +Widget _defaultRowSeparatorBuilder() => const VerticalDivider(); + class SeparatedColumn extends Column { SeparatedColumn({ super.key, @@ -11,7 +14,7 @@ class SeparatedColumn extends Column { super.textBaseline, super.textDirection, super.verticalDirection, - required SeparatorBuilder separatorBuilder, + SeparatorBuilder separatorBuilder = _defaultColumnSeparatorBuilder, required List children, }) : super(children: _insertSeparators(children, separatorBuilder)); } @@ -25,7 +28,7 @@ class SeparatedRow extends Row { super.textBaseline, super.textDirection, super.verticalDirection, - required SeparatorBuilder separatorBuilder, + SeparatorBuilder separatorBuilder = _defaultRowSeparatorBuilder, required List children, }) : super(children: _insertSeparators(children, separatorBuilder)); }