feat: feature flag in settings page (#4833)

This commit is contained in:
Lucas.Xu 2024-03-07 09:28:58 +08:00 committed by GitHub
parent f484675008
commit a1823fa4c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 204 additions and 5 deletions

View File

@ -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 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 /// the text to be too large and not aligned with the icon
static const String textScaleFactor = 'textScaleFactor'; 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';
} }

View File

@ -1,3 +1,4 @@
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/log.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@ -59,3 +60,15 @@ class TemporaryDirectoryCache implements ICache {
await tmpDir.delete(recursive: true); await tmpDir.delete(recursive: true);
} }
} }
class FeatureFlagCache implements ICache {
@override
Future<int> cacheSize() async {
return 0;
}
@override
Future<void> clearAll() async {
await FeatureFlag.clear();
}
}

View File

@ -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<FeatureFlag, bool>;
/// The [FeatureFlag] is used to control the front-end features of the app. /// The [FeatureFlag] is used to control the front-end features of the app.
/// ///
/// For example, if your feature is still under development, /// For example, if your feature is still under development,
/// you can set the value to `false` to hide the feature. /// you can set the value to `false` to hide the feature.
enum FeatureFlag { enum FeatureFlag {
// Feature flags
// used to control the visibility of the collaborative workspace feature // used to control the visibility of the collaborative workspace feature
// if it's on, you can see the workspace list and the workspace settings // if it's on, you can see the workspace list and the workspace settings
// in the top-left corner of the app // 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 // if it's on, you can see the members settings in the settings page
membersSettings; membersSettings;
static Future<void> initialize() async {
final values = await getIt<KeyValueStorage>().getWithFormat<FeatureFlagMap>(
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<FeatureFlag, bool> get data =>
UnmodifiableMapView(_values);
Future<void> turnOn() async {
await update(true);
}
Future<void> turnOff() async {
await update(false);
}
Future<void> update(bool value) async {
_values[this] = value;
await getIt<KeyValueStorage>().set(
KVKeys.featureFlag,
jsonEncode(
_values.map((key, value) => MapEntry(key.name, value)),
),
);
}
static Future<void> clear() async {
_values = {};
await getIt<KeyValueStorage>().remove(KVKeys.featureFlag);
}
bool get isOn { bool get isOn {
if (_values.containsKey(this)) {
return _values[this]!;
}
switch (this) { switch (this) {
case FeatureFlag.collaborativeWorkspace: case FeatureFlag.collaborativeWorkspace:
return false; return false;
@ -22,4 +78,17 @@ enum FeatureFlag {
return false; 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 = {};

View File

@ -134,7 +134,8 @@ void _resolveCommonService(
getIt.registerFactory<FlowyCacheManager>( getIt.registerFactory<FlowyCacheManager>(
() => FlowyCacheManager() () => FlowyCacheManager()
..registerCache(TemporaryDirectoryCache()) ..registerCache(TemporaryDirectoryCache())
..registerCache(CustomImageCacheManager()), ..registerCache(CustomImageCacheManager())
..registerCache(FeatureFlagCache()),
); );
} }

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:appflowy/env/cloud_env.dart'; 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/workspace/application/settings/prelude.dart';
import 'package:appflowy_backend/appflowy_backend.dart'; import 'package:appflowy_backend/appflowy_backend.dart';
import 'package:flutter/foundation.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. // there's a flag named _enable in memory_leak_detector.dart. If it's false, the task will be ignored.
MemoryLeakDetectorTask(), MemoryLeakDetectorTask(),
const DebugTask(), const DebugTask(),
const FeatureFlagTask(),
// localization // localization
const InitLocalizationTask(), const InitLocalizationTask(),

View File

@ -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<void> initialize(LaunchContext context) async {
// the hotkey manager is not supported on mobile
if (!kDebugMode) {
return;
}
await FeatureFlag.initialize();
}
@override
Future<void> dispose() async {}
}

View File

@ -17,6 +17,7 @@ enum SettingsPage {
cloud, cloud,
shortcuts, shortcuts,
member, member,
featureFlags,
} }
class SettingsDialogBloc class SettingsDialogBloc

View File

@ -1,6 +1,7 @@
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.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/members/workspace_member_page.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance_view.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance_view.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/settings_customize_shortcuts_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(); return const SettingsCustomizeShortcutsWrapper();
case SettingsPage.member: case SettingsPage.member:
return WorkspaceMembersPage(userProfile: user); return WorkspaceMembersPage(userProfile: user);
case SettingsPage.featureFlags:
return const FeatureFlagsPage();
default: default:
return const SizedBox.shrink(); return const SizedBox.shrink();
} }

View File

@ -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);
});
},
),
);
}
}

View File

@ -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:appflowy/workspace/presentation/settings/widgets/settings_menu_element.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class SettingsMenu extends StatelessWidget { class SettingsMenu extends StatelessWidget {
@ -79,6 +80,15 @@ class SettingsMenu extends StatelessWidget {
icon: Icons.people, icon: Icons.people,
changeSelectedPage: changeSelectedPage, changeSelectedPage: changeSelectedPage,
), ),
if (kDebugMode)
SettingsMenuElement(
// no need to translate this page
page: SettingsPage.featureFlags,
selectedPage: currentPage,
label: 'Feature Flags',
icon: Icons.flag,
changeSelectedPage: changeSelectedPage,
),
], ],
), ),
); );

View File

@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
typedef SeparatorBuilder = Widget Function(); typedef SeparatorBuilder = Widget Function();
Widget _defaultColumnSeparatorBuilder() => const Divider();
Widget _defaultRowSeparatorBuilder() => const VerticalDivider();
class SeparatedColumn extends Column { class SeparatedColumn extends Column {
SeparatedColumn({ SeparatedColumn({
super.key, super.key,
@ -11,7 +14,7 @@ class SeparatedColumn extends Column {
super.textBaseline, super.textBaseline,
super.textDirection, super.textDirection,
super.verticalDirection, super.verticalDirection,
required SeparatorBuilder separatorBuilder, SeparatorBuilder separatorBuilder = _defaultColumnSeparatorBuilder,
required List<Widget> children, required List<Widget> children,
}) : super(children: _insertSeparators(children, separatorBuilder)); }) : super(children: _insertSeparators(children, separatorBuilder));
} }
@ -25,7 +28,7 @@ class SeparatedRow extends Row {
super.textBaseline, super.textBaseline,
super.textDirection, super.textDirection,
super.verticalDirection, super.verticalDirection,
required SeparatorBuilder separatorBuilder, SeparatorBuilder separatorBuilder = _defaultRowSeparatorBuilder,
required List<Widget> children, required List<Widget> children,
}) : super(children: _insertSeparators(children, separatorBuilder)); }) : super(children: _insertSeparators(children, separatorBuilder));
} }