feat: Customize the storage folder path (#1538)

* feat: support customize folder path

* feat: add l10n and optimize the logic

* chore: code refactor

* feat: add file read/write permission for macOS

* fix: add toast for restoring path

* feat: fetch apps and show them

* feat: fetch apps and show them

* feat: implement select document logic

* feat: l10n and add select item callback

* feat: add space between tile

* chore: move file exporter to settings

* chore: update UI

* feat: support customizing folder when launching the app

* feat: auto register after customizing folder

* feat: l10n

* feat: l10n

* chore: reinitialize flowy sdk when calling init_sdk

* chore: remove flowysdk const keyword to make sure it can be rebuild

* chore: clear kv values when user logout

* chore: replace current workspace id key in kv.db

* feat: add config.name as a part of seesion_cache_key

* feat: support open folder when launching

* chore: fix some bugs

* chore: dart fix & flutter analyze

* chore: wrap 'sign up with ramdom user' as interface

* feat: dismiss settings view after changing the folder

* fix: read kv value after initializaing with new path

* chore: remove user_id prefix from current workspace key

* fix: move open latest view action to bloc

* test: add test utils for integration tests

* chore: move integration_test to its parent directory

* test: add integration_test ci

* test: switch to B from A, then switch to A again

* chore: fix warings and format code and fix tests

* chore: remove comment out codes

* chore: rename some properties name and optimize the logic

* chore: abstract logic of settings file exporter widget to cubit

* chore: abstract location customizer view from file system view

* chore: abstract settings page index to enum type

* chore: remove the redundant underscore

* test: fix integration test error

* chore: enable integration test for windows and ubuntu

* feat: abstract file picker as service and mock it under integration test

* chore: fix bloc test

Co-authored-by: nathan <nathan@appflowy.io>
This commit is contained in:
Lucas.Xu 2022-12-20 11:14:42 +08:00 committed by GitHub
parent 079fab6118
commit 5d7008edd7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 1837 additions and 302 deletions

View File

@ -44,6 +44,7 @@ jobs:
- uses: codecov/codecov-action@v3
with:
name: appflowy_editor
flags: appflowy editor
env_vars: ${{ matrix.os }}
fail_ci_if_error: true
verbose: true

128
.github/workflows/integration_test.yml vendored Normal file
View File

@ -0,0 +1,128 @@
name: integration test
on:
push:
branches:
- "main"
- "release/*"
paths:
- "frontend/app_flowy/**"
pull_request:
branches:
- "main"
- "release/*"
paths:
- "frontend/app_flowy/**"
env:
CARGO_TERM_COLOR: always
jobs:
tests:
strategy:
matrix:
os: [macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
toolchain: "stable-2022-04-07"
- uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.0.5"
cache: true
- name: Cache Cargo
uses: actions/cache@v2
with:
path: |
~/.cargo
key: ${{ runner.os }}-cargo-${{ steps.rust_toolchain.outputs.rustc_hash }}-${{ hashFiles('./frontend/rust-lib/Cargo.toml') }}
- name: Cache Rust
id: cache-rust-target
uses: actions/cache@v2
with:
path: |
frontend/rust-lib/target
shared-lib/target
key: ${{ runner.os }}-rust-rust-lib-share-lib-${{ steps.rust_toolchain.outputs.rustc_hash }}-${{ hashFiles('./frontend/rust-lib/Cargo.toml') }}
- name: Setup Environment
run: |
if [ "$RUNNER_OS" == "Linux" ]; then
sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub
sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list
sudo apt-get update
sudo apt-get install -y dart curl build-essential libsqlite3-dev libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev
sudo apt-get install keybinder-3.0
elif [ "$RUNNER_OS" == "Windows" ]; then
vcpkg integrate install
cargo install --force duckscript_cli
elif [ "$RUNNER_OS" == "macOS" ]; then
echo 'do nothing'
fi
shell: bash
- if: steps.cache-cargo.outputs.cache-hit != 'true'
name: Rust Deps
working-directory: frontend
run: |
cargo install cargo-make
cargo make appflowy-deps-tools
- name: Build Test lib
working-directory: frontend
run: |
if [ "$RUNNER_OS" == "Linux" ]; then
cargo make --profile production-linux-x86_64 appflowy
elif [ "$RUNNER_OS" == "macOS" ]; then
cargo make --profile production-mac-x86_64 appflowy
elif [ "$RUNNER_OS" == "Windows" ]; then
cargo make --profile production-windows-x86 appflowy
fi
- name: Config Flutter
run: |
if [ "$RUNNER_OS" == "Linux" ]; then
flutter config --enable-linux-desktop
elif [ "$RUNNER_OS" == "macOS" ]; then
flutter config --enable-macos-desktop
elif [ "$RUNNER_OS" == "Windows" ]; then
flutter config --enable-windows-desktop
fi
shell: bash
- name: Flutter Code Generation
working-directory: frontend/app_flowy
run: |
flutter packages pub get
flutter packages pub run easy_localization:generate -f keys -o locale_keys.g.dart -S assets/translations -s en.json
flutter packages pub run build_runner build --delete-conflicting-outputs
- name: Run AppFlowy tests
working-directory: frontend/app_flowy
run: |
if [ "$RUNNER_OS" == "Linux" ]; then
flutter test integration_test -d Linux --coverage
elif [ "$RUNNER_OS" == "macOS" ]; then
flutter test integration_test -d macOS --coverage
elif [ "$RUNNER_OS" == "Windows" ]; then
flutter test integration_test -d Windows --coverage
fi
shell: bash
- uses: codecov/codecov-action@v3
with:
name: appflowy
flags: appflowy
env_vars: ${{ matrix.os }}
fail_ci_if_error: true
verbose: true

View File

@ -155,6 +155,7 @@
"appearance": "Appearance",
"language": "Language",
"user": "User",
"files": "Files",
"open": "Open Settings"
},
"appearance": {
@ -164,6 +165,26 @@
"dark": "Dark Mode",
"system": "Adapt to System"
}
},
"files": {
"defaultLocation": "Default location for new notes",
"doubleTapToCopy": "Double tap to copy the path",
"restoreLocation": "Restore to default location",
"customizeLocation": "Customize location",
"restartApp": "Please restart app for the changes to take effect.",
"exportDatabase": "Export databae",
"selectFiles": "Select the files that need to be export",
"createNewFolder": "Create a new folder",
"createNewFolderDesc": "Create a new folder ...",
"open": "Open",
"openFolder": "Open folder",
"openFolderDesc": "Open folder ...",
"folderHintText": "folder name",
"location": "Location",
"locationDesc": "Pick a name for your location",
"browser": "Browser",
"create": "create",
"locationCannotBeEmpty": "Location cannot be empty"
}
},
"grid": {

View File

@ -0,0 +1,161 @@
import 'package:app_flowy/user/presentation/folder/folder_widget.dart';
import 'package:flowy_infra_ui/style_widget/text_field.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'util/mock/mock_file_picker.dart';
import 'util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('customize the folder path', () {
const location = 'appflowy';
setUp(() async {
await TestFolder.cleanTestLocation(location);
await TestFolder.setTestLocation(location);
});
tearDown(() async {
await TestFolder.cleanTestLocation(location);
});
tearDownAll(() async {
await TestFolder.cleanTestLocation(null);
});
testWidgets(
'customize folder name and path when launching app in first time',
(tester) async {
const folderName = 'appflowy';
await TestFolder.cleanTestLocation(folderName);
await tester.initializeAppFlowy();
// Click create button
await tester.tapCreateButton();
// Set directory
final cfw = find.byType(CreateFolderWidget);
expect(cfw, findsOneWidget);
final state = tester.state(cfw) as CreateFolderWidgetState;
final dir = await TestFolder.testLocation(null);
state.directory = dir.path;
// input folder name
final ftf = find.byType(FlowyTextField);
expect(ftf, findsOneWidget);
await tester.enterText(ftf, 'appflowy');
// Click create button again
await tester.tapCreateButton();
await tester.expectToSeeWelcomePage();
await TestFolder.cleanTestLocation(folderName);
});
testWidgets('open a new folder when launching app in first time',
(tester) async {
const folderName = 'appflowy';
await TestFolder.cleanTestLocation(folderName);
await TestFolder.setTestLocation(folderName);
await tester.initializeAppFlowy();
// tap open button
await mockGetDirectoryPath(folderName);
await tester.tapOpenFolderButton();
await tester.wait(1000);
await tester.expectToSeeWelcomePage();
await TestFolder.cleanTestLocation(folderName);
});
testWidgets('switch to B from A, then switch to A again', (tester) async {
const String userA = 'userA';
const String userB = 'userB';
await TestFolder.cleanTestLocation(userA);
await TestFolder.setTestLocation(userA);
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.expectToSeeWelcomePage();
// swith to user B
{
await tester.openSettings();
await tester.openSettingsPage(SettingsPage.user);
await tester.enterUserName(userA);
await tester.openSettingsPage(SettingsPage.files);
await tester.pumpAndSettle();
// mock the file_picker result
await mockGetDirectoryPath(userB);
await tester.tapCustomLocationButton();
await tester.pumpAndSettle();
await tester.expectToSeeWelcomePage();
}
// switch to the userA
{
await tester.openSettings();
await tester.openSettingsPage(SettingsPage.user);
await tester.enterUserName(userB);
await tester.openSettingsPage(SettingsPage.files);
await tester.pumpAndSettle();
// mock the file_picker result
await mockGetDirectoryPath(userA);
await tester.tapCustomLocationButton();
await tester.pumpAndSettle();
await tester.expectToSeeWelcomePage();
expect(find.textContaining(userA), findsOneWidget);
}
// swith to the userB again
{
await tester.openSettings();
await tester.openSettingsPage(SettingsPage.files);
await tester.pumpAndSettle();
// mock the file_picker result
await mockGetDirectoryPath(userB);
await tester.tapCustomLocationButton();
await tester.pumpAndSettle();
await tester.expectToSeeWelcomePage();
expect(find.textContaining(userB), findsOneWidget);
}
await TestFolder.cleanTestLocation(userA);
await TestFolder.cleanTestLocation(userB);
});
testWidgets('reset to default location', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// home and readme document
await tester.expectToSeeWelcomePage();
// open settings and restore the location
await tester.openSettings();
await tester.openSettingsPage(SettingsPage.files);
await tester.restoreLocation();
expect(
await TestFolder.defaultDevelopmentLocation(),
await TestFolder.currentLocation(),
);
});
});
}

View File

@ -0,0 +1,114 @@
import 'dart:io';
import 'package:app_flowy/main.dart' as app;
import 'package:app_flowy/startup/tasks/prelude.dart';
import 'package:app_flowy/workspace/application/settings/settings_location_cubit.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
class TestFolder {
/// Location / Path
/// Set a given AppFlowy data storage location under test environment.
///
/// To pass null means clear the location.
///
/// The file_picker is a system component and can't be tapped, so using logic instead of tapping.
///
static Future<void> setTestLocation(String? name) async {
final location = await testLocation(name);
SharedPreferences.setMockInitialValues({
kSettingsLocationDefaultLocation: location.path,
});
return;
}
/// Clean the location.
static Future<void> cleanTestLocation(String? name) async {
final dir = await testLocation(name);
await dir.delete(recursive: true);
return;
}
/// Get current using location.
static Future<String> currentLocation() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(kSettingsLocationDefaultLocation)!;
}
/// Get default location under development environment.
static Future<String> defaultDevelopmentLocation() async {
final dir = await appFlowyDocumentDirectory();
return dir.path;
}
/// Get default location under test environment.
static Future<Directory> testLocation(String? name) async {
final dir = await getApplicationDocumentsDirectory();
var path = '${dir.path}/flowy_test';
if (name != null) {
path += '/$name';
}
return Directory(path).create(recursive: true);
}
}
extension AppFlowyTestBase on WidgetTester {
Future<void> initializeAppFlowy() async {
const MethodChannel('hotkey_manager')
.setMockMethodCallHandler((MethodCall methodCall) async {
if (methodCall.method == 'unregisterAll') {
// do nothing
}
});
await app.main();
await wait(3000);
await pumpAndSettle(const Duration(seconds: 2));
return;
}
Future<void> tapButton(
Finder finder, {
int? pointer,
int buttons = kPrimaryButton,
bool warnIfMissed = true,
int milliseconds = 500,
}) async {
await tap(finder);
await pumpAndSettle(Duration(milliseconds: milliseconds));
return;
}
Future<void> tapButtonWithName(
String tr, {
int milliseconds = 500,
}) async {
final button = find.textContaining(tr);
await tapButton(
button,
milliseconds: milliseconds,
);
return;
}
Future<void> tapButtonWithTooltip(
String tr, {
int milliseconds = 500,
}) async {
final button = find.byTooltip(tr);
await tapButton(
button,
milliseconds: milliseconds,
);
return;
}
Future<void> wait(int milliseconds) async {
await pumpAndSettle(Duration(milliseconds: milliseconds));
return;
}
}

View File

@ -0,0 +1,23 @@
import 'package:app_flowy/generated/locale_keys.g.dart';
import 'package:app_flowy/workspace/presentation/home/home_stack.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart';
import 'base.dart';
extension AppFlowyLaunch on WidgetTester {
Future<void> tapGoButton() async {
await tapButtonWithName(LocaleKeys.letsGoButtonText.tr());
return;
}
Future<void> tapCreateButton() async {
await tapButtonWithName(LocaleKeys.settings_files_create.tr());
return;
}
Future<void> expectToSeeWelcomePage() async {
expect(find.byType(HomeStack), findsOneWidget);
expect(find.textContaining('Read me'), findsNWidgets(2));
}
}

View File

@ -0,0 +1,29 @@
import 'package:app_flowy/startup/startup.dart';
import 'package:app_flowy/util/file_picker/file_picker_impl.dart';
import 'package:app_flowy/util/file_picker/file_picker_service.dart';
import '../util.dart';
class MockFilePicker extends FilePicker {
MockFilePicker({
required this.mockPath,
});
final String mockPath;
@override
Future<String?> getDirectoryPath({String? title}) {
return Future.value(mockPath);
}
}
Future<void> mockGetDirectoryPath(String? name) async {
final dir = await TestFolder.testLocation(name);
getIt.unregister<FilePickerService>();
getIt.registerFactory<FilePickerService>(
() => MockFilePicker(
mockPath: dir.path,
),
);
return;
}

View File

@ -0,0 +1,84 @@
import 'package:app_flowy/generated/locale_keys.g.dart';
import 'package:app_flowy/workspace/presentation/settings/settings_dialog.dart';
import 'package:app_flowy/workspace/presentation/settings/widgets/settings_user_view.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart';
import 'base.dart';
enum SettingsPage {
appearance,
language,
files,
user,
}
extension on SettingsPage {
String get name {
switch (this) {
case SettingsPage.appearance:
return LocaleKeys.settings_menu_appearance.tr();
case SettingsPage.language:
return LocaleKeys.settings_menu_language.tr();
case SettingsPage.files:
return LocaleKeys.settings_menu_files.tr();
case SettingsPage.user:
return LocaleKeys.settings_menu_user.tr();
}
}
}
extension AppFlowySettings on WidgetTester {
/// Open settings page
Future<void> openSettings() async {
final settingsButton = find.byTooltip(LocaleKeys.settings_menu_open.tr());
expect(settingsButton, findsOneWidget);
await tapButton(settingsButton);
final settingsDialog = find.byType(SettingsDialog);
expect(settingsDialog, findsOneWidget);
return;
}
/// Open the page taht insides the settings page
Future<void> openSettingsPage(SettingsPage page) async {
final button = find.text(page.name, findRichText: true);
expect(button, findsOneWidget);
await tapButton(button);
return;
}
/// Restore the AppFlowy data storage location
Future<void> restoreLocation() async {
final buton =
find.byTooltip(LocaleKeys.settings_files_restoreLocation.tr());
expect(buton, findsOneWidget);
await tapButton(buton);
return;
}
Future<void> tapOpenFolderButton() async {
final buton = find.text(LocaleKeys.settings_files_open.tr());
expect(buton, findsOneWidget);
await tapButton(buton);
return;
}
Future<void> tapCustomLocationButton() async {
final buton =
find.byTooltip(LocaleKeys.settings_files_customizeLocation.tr());
expect(buton, findsOneWidget);
await tapButton(buton);
return;
}
/// Enter user name
Future<void> enterUserName(String name) async {
final uni = find.byType(UserNameInput);
expect(uni, findsOneWidget);
await tap(uni);
await enterText(uni, name);
await wait(300); //
await testTextInput.receiveAction(TextInputAction.done);
await pumpAndSettle();
}
}

View File

@ -0,0 +1,3 @@
export 'base.dart';
export 'launch.dart';
export 'settings.dart';

View File

@ -1,17 +1,21 @@
import 'package:app_flowy/startup/startup.dart';
import 'package:app_flowy/user/presentation/splash_screen.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:flutter/material.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'startup/launch_configuration.dart';
import 'startup/startup.dart';
import 'user/presentation/splash_screen.dart';
class FlowyApp implements EntryPoint {
@override
Widget create() {
return const SplashScreen();
Widget create(LaunchConfiguration config) {
return SplashScreen(
autoRegister: config.autoRegistrationSupported,
);
}
}
void main() async {
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await EasyLocalization.ensureInitialized();

View File

@ -22,7 +22,7 @@ class DocumentMoreButton extends StatelessWidget {
value: context.read<DocumentAppearanceCubit>(),
child: const FontSizeSwitcher(),
),
)
),
];
},
child: svgWidget(

View File

@ -1,18 +1,18 @@
import 'package:app_flowy/plugins/grid/application/field/field_cell_bloc.dart';
import 'package:app_flowy/plugins/grid/application/field/field_service.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra/image.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../layout/sizes.dart';
import 'field_type_extension.dart';
import '../../../application/field/field_cell_bloc.dart';
import '../../../application/field/field_service.dart';
import '../../layout/sizes.dart';
import 'field_cell_action_sheet.dart';
import 'field_type_extension.dart';
class GridFieldCell extends StatefulWidget {
final GridFieldCellContext cellContext;
@ -122,7 +122,6 @@ class _DragToExpandLine extends StatelessWidget {
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragUpdate: (value) {
debugPrint("update new width: ${value.delta.dx}");
context
.read<FieldCellBloc>()
.add(FieldCellEvent.startUpdateWidth(value.delta.dx));

View File

@ -1,9 +1,12 @@
import 'package:app_flowy/core/network_monitor.dart';
import 'package:app_flowy/user/application/user_listener.dart';
import 'package:app_flowy/user/application/user_service.dart';
import 'package:app_flowy/util/file_picker/file_picker_impl.dart';
import 'package:app_flowy/util/file_picker/file_picker_service.dart';
import 'package:app_flowy/workspace/application/app/prelude.dart';
import 'package:app_flowy/plugins/document/application/prelude.dart';
import 'package:app_flowy/plugins/grid/application/prelude.dart';
import 'package:app_flowy/workspace/application/settings/settings_location_cubit.dart';
import 'package:app_flowy/workspace/application/user/prelude.dart';
import 'package:app_flowy/workspace/application/workspace/prelude.dart';
import 'package:app_flowy/workspace/application/edit_panel/edit_panel_bloc.dart';
@ -34,9 +37,15 @@ class DependencyResolver {
_resolveDocDeps(getIt);
_resolveGridDeps(getIt);
_resolveCommonService(getIt);
}
}
void _resolveCommonService(GetIt getIt) {
getIt.registerFactory<FilePickerService>(() => FilePicker());
}
void _resolveUserDeps(GetIt getIt) {
getIt.registerFactory<AuthService>(() => AuthService());
getIt.registerFactory<AuthRouter>(() => AuthRouter());
@ -101,6 +110,11 @@ void _resolveFolderDeps(GetIt getIt) {
(user, _) => SettingsDialogBloc(user),
);
// Location
getIt.registerFactory<SettingsLocationCubit>(
() => SettingsLocationCubit(),
);
//User
getIt.registerFactoryParam<SettingsUserViewBloc, UserProfilePB, void>(
(user, _) => SettingsUserViewBloc(user),

View File

@ -0,0 +1,8 @@
class LaunchConfiguration {
const LaunchConfiguration({
this.autoRegistrationSupported = false,
});
// APP will automatically register after launching.
final bool autoRegistrationSupported;
}

View File

@ -1,12 +1,15 @@
import 'dart:io';
import 'package:app_flowy/startup/plugin/plugin.dart';
import 'package:app_flowy/startup/tasks/prelude.dart';
import 'package:app_flowy/startup/deps_resolver.dart';
import 'package:flowy_sdk/flowy_sdk.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:flowy_sdk/flowy_sdk.dart';
import '../workspace/application/settings/settings_location_cubit.dart';
import 'deps_resolver.dart';
import 'launch_configuration.dart';
import 'plugin/plugin.dart';
import 'tasks/prelude.dart';
// [[diagram: flowy startup flow]]
//
@ -28,17 +31,28 @@ import 'package:flowy_sdk/flowy_sdk.dart';
final getIt = GetIt.instance;
abstract class EntryPoint {
Widget create();
Widget create(LaunchConfiguration config);
}
class FlowyRunner {
static Future<void> run(EntryPoint f) async {
static Future<void> run(
EntryPoint f, {
LaunchConfiguration config =
const LaunchConfiguration(autoRegistrationSupported: false),
}) async {
// Clear all the states in case of rebuilding.
await getIt.reset();
// Specify the env
final env = integrationEnv();
initGetIt(getIt, env, f);
initGetIt(getIt, env, f, config);
final directory = getIt<SettingsLocationCubit>()
.fetchLocation()
.then((value) => Directory(value));
// add task
getIt<AppLauncher>().addTask(InitRustSDKTask());
getIt<AppLauncher>().addTask(InitRustSDKTask(directory: directory));
getIt<AppLauncher>().addTask(PluginLoadTask());
if (!env.isTest()) {
@ -47,7 +61,7 @@ class FlowyRunner {
}
// execute the tasks
getIt<AppLauncher>().launch();
await getIt<AppLauncher>().launch();
}
}
@ -55,10 +69,21 @@ Future<void> initGetIt(
GetIt getIt,
IntegrationMode env,
EntryPoint f,
LaunchConfiguration config,
) async {
getIt.registerFactory<EntryPoint>(() => f);
getIt.registerLazySingleton<FlowySDK>(() => const FlowySDK());
getIt.registerLazySingleton<AppLauncher>(() => AppLauncher(env, getIt));
getIt.registerLazySingleton<FlowySDK>(() {
return FlowySDK();
});
getIt.registerLazySingleton<AppLauncher>(
() => AppLauncher(
context: LaunchContext(
getIt,
env,
config,
),
),
);
getIt.registerSingleton<PluginSandbox>(PluginSandbox());
await DependencyResolver.resolve(getIt);
@ -67,7 +92,8 @@ Future<void> initGetIt(
class LaunchContext {
GetIt getIt;
IntegrationMode env;
LaunchContext(this.getIt, this.env);
LaunchConfiguration config;
LaunchContext(this.getIt, this.env, this.config);
}
enum LaunchTaskType {
@ -84,17 +110,16 @@ abstract class LaunchTask {
class AppLauncher {
List<LaunchTask> tasks;
IntegrationMode env;
GetIt getIt;
AppLauncher(this.env, this.getIt) : tasks = List.from([]);
final LaunchContext context;
AppLauncher({required this.context}) : tasks = List.from([]);
void addTask(LaunchTask task) {
tasks.add(task);
}
Future<void> launch() async {
final context = LaunchContext(getIt, env);
for (var task in tasks) {
await task.initialize(context);
}

View File

@ -1,6 +1,3 @@
import 'package:app_flowy/startup/startup.dart';
import 'package:app_flowy/user/application/user_settings_service.dart';
import 'package:app_flowy/workspace/application/appearance.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@ -10,13 +7,17 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:window_size/window_size.dart';
import '../../user/application/user_settings_service.dart';
import '../../workspace/application/appearance.dart';
import '../startup.dart';
class InitAppWidgetTask extends LaunchTask {
@override
LaunchTaskType get type => LaunchTaskType.appLauncher;
@override
Future<void> initialize(LaunchContext context) async {
final widget = context.getIt<EntryPoint>().create();
final widget = context.getIt<EntryPoint>().create(context.config);
final appearanceSetting = await SettingsFFIService().getAppearanceSetting();
final app = ApplicationWidget(
appearanceSetting: appearanceSetting,

View File

@ -1,17 +1,33 @@
import 'dart:io';
import 'package:app_flowy/startup/startup.dart';
import 'package:path_provider/path_provider.dart';
import 'package:flowy_sdk/flowy_sdk.dart';
import 'package:path_provider/path_provider.dart';
import '../startup.dart';
class InitRustSDKTask extends LaunchTask {
InitRustSDKTask({
this.directory,
});
// Customize the RustSDK initialization path
final Future<Directory>? directory;
@override
LaunchTaskType get type => LaunchTaskType.dataProcessing;
@override
Future<void> initialize(LaunchContext context) async {
await appFlowyDocumentDirectory().then((directory) async {
await context.getIt<FlowySDK>().init(directory);
});
// use the custom directory
if (directory != null) {
return directory!.then((directory) async {
await context.getIt<FlowySDK>().init(directory);
});
} else {
return appFlowyDocumentDirectory().then((directory) async {
await context.getIt<FlowySDK>().init(directory);
});
}
}
}

View File

@ -1,10 +1,16 @@
import 'package:dartz/dartz.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flowy_sdk/dispatch/dispatch.dart';
import 'package:flowy_sdk/protobuf/flowy-user/protobuf.dart' show SignInPayloadPB, SignUpPayloadPB, UserProfilePB;
import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-user/protobuf.dart'
show SignInPayloadPB, SignUpPayloadPB, UserProfilePB;
import '../../generated/locale_keys.g.dart';
class AuthService {
Future<Either<UserProfilePB, FlowyError>> signIn({required String? email, required String? password}) {
Future<Either<UserProfilePB, FlowyError>> signIn(
{required String? email, required String? password}) {
//
final request = SignInPayloadPB.create()
..email = email ?? ''
@ -14,7 +20,9 @@ class AuthService {
}
Future<Either<UserProfilePB, FlowyError>> signUp(
{required String? name, required String? password, required String? email}) {
{required String? name,
required String? password,
required String? email}) {
final request = SignUpPayloadPB.create()
..email = email ?? ''
..name = name ?? ''
@ -38,4 +46,15 @@ class AuthService {
Future<Either<Unit, FlowyError>> signOut() {
return UserEventSignOut().send();
}
Future<Either<UserProfilePB, FlowyError>> signUpWithRandomUser() {
const password = "AppFlowy123@";
final uid = uuid();
final userEmail = "$uid@appflowy.io";
return signUp(
name: LocaleKeys.defaultUsername.tr(),
password: password,
email: userEmail,
);
}
}

View File

@ -0,0 +1,250 @@
import 'dart:io';
import 'package:app_flowy/util/file_picker/file_picker_service.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/style_widget/text_field.dart';
import 'package:flowy_infra_ui/widget/rounded_button.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import '../../../generated/locale_keys.g.dart';
import '../../../startup/startup.dart';
import '../../../workspace/application/settings/settings_location_cubit.dart';
import '../../../workspace/presentation/home/toast.dart';
enum _FolderPage {
options,
create,
open,
}
class FolderWidget extends StatefulWidget {
const FolderWidget({
Key? key,
required this.createFolderCallback,
}) : super(key: key);
final Future<void> Function() createFolderCallback;
@override
State<FolderWidget> createState() => _FolderWidgetState();
}
class _FolderWidgetState extends State<FolderWidget> {
var page = _FolderPage.options;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 250,
child: _mapIndexToWidget(context),
);
}
Widget _mapIndexToWidget(BuildContext context) {
switch (page) {
case _FolderPage.options:
return FolderOptionsWidget(
onPressedCreate: () {
setState(() => page = _FolderPage.create);
},
onPressedOpen: () {
_openFolder();
},
);
case _FolderPage.create:
return CreateFolderWidget(
onPressedBack: () {
setState(() => page = _FolderPage.options);
},
onPressedCreate: widget.createFolderCallback,
);
case _FolderPage.open:
return Container();
}
}
Future<void> _openFolder() async {
final directory = await getIt<FilePickerService>().getDirectoryPath();
if (directory != null) {
await getIt<SettingsLocationCubit>().setLocation(directory);
await widget.createFolderCallback();
}
}
}
class FolderOptionsWidget extends StatelessWidget {
const FolderOptionsWidget({
Key? key,
required this.onPressedCreate,
required this.onPressedOpen,
}) : super(key: key);
final VoidCallback onPressedCreate;
final VoidCallback onPressedOpen;
@override
Widget build(BuildContext context) {
return ListView(
shrinkWrap: true,
children: <Widget>[
Card(
child: ListTile(
title: FlowyText.medium(
LocaleKeys.settings_files_createNewFolder.tr(),
),
subtitle: FlowyText.regular(
LocaleKeys.settings_files_createNewFolderDesc.tr(),
),
trailing: _buildTextButton(
context,
LocaleKeys.settings_files_create.tr(),
onPressedCreate,
),
),
),
Card(
child: ListTile(
title: FlowyText.medium(
LocaleKeys.settings_files_openFolder.tr(),
),
subtitle: FlowyText.regular(
LocaleKeys.settings_files_openFolderDesc.tr(),
),
trailing: _buildTextButton(
context,
LocaleKeys.settings_files_open.tr(),
onPressedOpen,
),
),
),
],
);
}
}
class CreateFolderWidget extends StatefulWidget {
const CreateFolderWidget({
Key? key,
required this.onPressedBack,
required this.onPressedCreate,
}) : super(key: key);
final VoidCallback onPressedBack;
final Future<void> Function() onPressedCreate;
@override
State<CreateFolderWidget> createState() => CreateFolderWidgetState();
}
@visibleForTesting
class CreateFolderWidgetState extends State<CreateFolderWidget> {
var _folderName = 'appflowy';
@visibleForTesting
var directory = '';
final _fToast = FToast();
@override
void initState() {
super.initState();
_fToast.init(context);
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: widget.onPressedBack,
icon: const Icon(Icons.arrow_back_rounded),
label: const Text('Back'),
),
),
Card(
child: ListTile(
title: FlowyText.medium(
LocaleKeys.settings_files_location.tr(),
),
subtitle: FlowyText.regular(
LocaleKeys.settings_files_locationDesc.tr(),
),
trailing: SizedBox(
width: 100,
height: 36,
child: FlowyTextField(
hintText: LocaleKeys.settings_files_folderHintText.tr(),
onChanged: (name) {
_folderName = name;
},
onSubmitted: (name) {
setState(() {
_folderName = name;
});
},
),
),
),
),
Card(
child: ListTile(
title: FlowyText.medium(LocaleKeys.settings_files_location.tr()),
subtitle: FlowyText.regular(_path),
trailing: _buildTextButton(
context, LocaleKeys.settings_files_browser.tr(), () async {
final dir = await getIt<FilePickerService>().getDirectoryPath();
if (dir != null) {
setState(() {
directory = dir;
});
}
}),
),
),
Card(
child: _buildTextButton(context, 'create', () async {
if (_path.isEmpty) {
_showToast(LocaleKeys.settings_files_locationCannotBeEmpty.tr());
} else {
await getIt<SettingsLocationCubit>().setLocation(_path);
await widget.onPressedCreate();
}
}),
)
],
);
}
String get _path {
if (directory.isEmpty) return '';
final String path;
if (Platform.isMacOS) {
path = directory.replaceAll('/Volumes/Macintosh HD', '');
} else {
path = directory;
}
return '$path/$_folderName';
}
void _showToast(String message) {
_fToast.showToast(
child: FlowyMessageToast(message: message),
gravity: ToastGravity.CENTER,
);
}
}
Widget _buildTextButton(
BuildContext context, String title, VoidCallback onPressed) {
return SizedBox(
width: 70,
height: 36,
child: RoundedTextButton(
title: title,
onPressed: onPressed,
),
);
}

View File

@ -30,7 +30,12 @@ class AuthRouter {
WorkspaceSettingPB workspaceSetting) {
Navigator.push(
context,
PageRoutes.fade(() => HomeScreen(profile, workspaceSetting),
PageRoutes.fade(
() => HomeScreen(
profile,
workspaceSetting,
key: ValueKey(profile.id),
),
RouteDurations.slow.inMilliseconds * .001),
);
}
@ -55,7 +60,12 @@ class SplashRoute {
WorkspaceSettingPB workspaceSetting) {
Navigator.push(
context,
PageRoutes.fade(() => HomeScreen(userProfile, workspaceSetting),
PageRoutes.fade(
() => HomeScreen(
userProfile,
workspaceSetting,
key: ValueKey(userProfile.id),
),
RouteDurations.slow.inMilliseconds * .001),
);
}

View File

@ -1,21 +1,25 @@
import 'package:app_flowy/user/application/auth_service.dart';
import 'package:app_flowy/user/presentation/router.dart';
import 'package:app_flowy/user/presentation/widgets/background.dart';
import 'package:dartz/dartz.dart' as dartz;
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/widget/rounded_button.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flowy_sdk/log.dart';
import 'package:flowy_sdk/dispatch/dispatch.dart';
import 'package:flowy_sdk/protobuf/flowy-folder/protobuf.dart';
import 'package:flowy_sdk/log.dart';
import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-folder/protobuf.dart';
import 'package:flowy_sdk/protobuf/flowy-user/user_profile.pb.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:dartz/dartz.dart' as dartz;
import 'package:app_flowy/generated/locale_keys.g.dart';
import '../../generated/locale_keys.g.dart';
import '../../main.dart';
import '../../startup/launch_configuration.dart';
import '../../startup/startup.dart';
import '../application/auth_service.dart';
import 'folder/folder_widget.dart';
import 'router.dart';
import 'widgets/background.dart';
class SkipLogInScreen extends StatefulWidget {
final AuthRouter router;
@ -36,11 +40,7 @@ class _SkipLogInScreenState extends State<SkipLogInScreen> {
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: SizedBox(
width: 400,
height: 600,
child: _renderBody(context),
),
child: _renderBody(context),
),
);
}
@ -53,33 +53,57 @@ class _SkipLogInScreenState extends State<SkipLogInScreen> {
title: LocaleKeys.welcomeText.tr(),
logoSize: const Size.square(60),
),
const VSpace(80),
GoButton(onPressed: () => _autoRegister(context)),
const VSpace(30),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
InkWell(
hoverColor: Colors.transparent,
onTap: () =>
_launchURL('https://github.com/AppFlowy-IO/appflowy'),
child: FlowyText.medium(
LocaleKeys.githubStarText.tr(),
color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
),
),
InkWell(
hoverColor: Colors.transparent,
onTap: () => _launchURL('https://www.appflowy.io/blog'),
child: FlowyText.medium(
LocaleKeys.subscribeNewsletterText.tr(),
color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
),
),
],
)
const VSpace(40),
SizedBox(
width: 250,
child: GoButton(onPressed: () => _autoRegister(context)),
),
const VSpace(20),
SizedBox(
width: MediaQuery.of(context).size.width * 0.8,
child: FolderWidget(
createFolderCallback: () async {
await FlowyRunner.run(
FlowyApp(),
config: const LaunchConfiguration(
autoRegistrationSupported: true,
),
);
},
),
),
const VSpace(20),
SizedBox(
width: 400,
child: _buildSubscribeButtons(context),
),
],
);
}
Row _buildSubscribeButtons(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
FlowyTextButton(
LocaleKeys.githubStarText.tr(),
fontWeight: FontWeight.w500,
fontColor: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
hoverColor: Colors.transparent,
fillColor: Colors.transparent,
onPressed: () =>
_launchURL('https://github.com/AppFlowy-IO/appflowy'),
),
FlowyTextButton(
LocaleKeys.subscribeNewsletterText.tr(),
fontWeight: FontWeight.w500,
fontColor: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
hoverColor: Colors.transparent,
fillColor: Colors.transparent,
onPressed: () => _launchURL('https://www.appflowy.io/blog'),
),
],
);
}
@ -93,15 +117,8 @@ class _SkipLogInScreenState extends State<SkipLogInScreen> {
}
}
void _autoRegister(BuildContext context) async {
const password = "AppFlowy123@";
final uid = uuid();
final userEmail = "$uid@appflowy.io";
final result = await widget.authService.signUp(
name: LocaleKeys.defaultUsername.tr(),
password: password,
email: userEmail,
);
Future<void> _autoRegister(BuildContext context) async {
final result = await widget.authService.signUpWithRandomUser();
result.fold(
(user) {
FolderEventReadCurrentWorkspace().send().then((result) {

View File

@ -1,13 +1,15 @@
import 'package:app_flowy/startup/startup.dart';
import 'package:app_flowy/user/application/splash_bloc.dart';
import 'package:app_flowy/user/domain/auth_state.dart';
import 'package:app_flowy/user/presentation/router.dart';
import 'package:flowy_sdk/log.dart';
import 'package:flowy_sdk/dispatch/dispatch.dart';
import 'package:flowy_sdk/log.dart';
import 'package:flowy_sdk/protobuf/flowy-error-code/code.pb.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../startup/startup.dart';
import '../application/auth_service.dart';
import '../application/splash_bloc.dart';
import '../domain/auth_state.dart';
import 'router.dart';
// [[diagram: splash screen]]
// 1.get user 2.send UserEventCheckUser
// SplashScreen SplashBlocISplashUser
@ -19,10 +21,31 @@ import 'package:flutter_bloc/flutter_bloc.dart';
//
// 4. Show HomeScreen or SignIn 3.return AuthState
class SplashScreen extends StatelessWidget {
const SplashScreen({Key? key}) : super(key: key);
const SplashScreen({
Key? key,
required this.autoRegister,
}) : super(key: key);
final bool autoRegister;
@override
Widget build(BuildContext context) {
if (!autoRegister) {
return _buildChild(context);
} else {
return FutureBuilder<void>(
future: _registerIfNeeded(),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return Container();
}
return _buildChild(context);
},
);
}
}
BlocProvider<SplashBloc> _buildChild(BuildContext context) {
return BlocProvider(
create: (context) {
return getIt<SplashBloc>()..add(const SplashEvent.getUser());
@ -47,8 +70,10 @@ class SplashScreen extends StatelessWidget {
FolderEventReadCurrentWorkspace().send().then(
(result) {
return result.fold(
(workspaceSetting) => getIt<SplashRoute>()
.pushHomeScreen(context, userProfile, workspaceSetting),
(workspaceSetting) {
getIt<SplashRoute>()
.pushHomeScreen(context, userProfile, workspaceSetting);
},
(error) async {
Log.error(error);
assert(error.code == ErrorCode.RecordNotFound.value);
@ -63,6 +88,13 @@ class SplashScreen extends StatelessWidget {
// getIt<SplashRoute>().pushSignInScreen(context);
getIt<SplashRoute>().pushSkipLoginScreen(context);
}
Future<void> _registerIfNeeded() async {
final result = await UserEventCheckUser().send();
if (!result.isLeft()) {
await getIt<AuthService>().signUpWithRandomUser();
}
}
}
class Body extends StatelessWidget {
@ -78,11 +110,12 @@ class Body extends StatelessWidget {
alignment: Alignment.center,
children: [
Image(
fit: BoxFit.cover,
width: size.width,
height: size.height,
image: const AssetImage(
'assets/images/appflowy_launch_splash.jpg')),
fit: BoxFit.cover,
width: size.width,
height: size.height,
image:
const AssetImage('assets/images/appflowy_launch_splash.jpg'),
),
const CircularProgressIndicator.adaptive(),
],
),

View File

@ -0,0 +1,9 @@
import 'package:app_flowy/util/file_picker/file_picker_service.dart';
import 'package:file_picker/file_picker.dart' as fp;
class FilePicker implements FilePickerService {
@override
Future<String?> getDirectoryPath({String? title}) {
return fp.FilePicker.platform.getDirectoryPath();
}
}

View File

@ -0,0 +1,16 @@
import 'package:file_picker/file_picker.dart';
class FilePickerResult {
const FilePickerResult(this.files);
/// Picked files.
final List<PlatformFile> files;
}
/// Abstract file picker as a service to implement dependency injection.
abstract class FilePickerService {
Future<String?> getDirectoryPath({
String? title,
}) async =>
throw UnimplementedError('getDirectoryPath() has not been implemented.');
}

View File

@ -3,6 +3,7 @@ import 'package:flowy_infra/time/duration.dart';
import 'package:flowy_sdk/log.dart';
import 'package:flowy_sdk/protobuf/flowy-error-code/code.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-folder/workspace.pb.dart'
show WorkspaceSettingPB;
import 'package:flowy_sdk/protobuf/flowy-user/user_profile.pb.dart';
@ -23,6 +24,12 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
(event, emit) async {
await event.map(
initial: (_Initial value) {
Future.delayed(const Duration(milliseconds: 300), () {
if (!isClosed) {
add(HomeEvent.didReceiveWorkspaceSetting(workspaceSetting));
}
});
_listener.start(
onAuthChanged: (result) => _authDidChanged(result),
onSettingUpdated: (result) {
@ -38,7 +45,14 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
emit(state.copyWith(isLoading: e.isLoading));
},
didReceiveWorkspaceSetting: (_DidReceiveWorkspaceSetting value) {
emit(state.copyWith(workspaceSetting: value.setting));
final latestView = workspaceSetting.hasLatestView()
? workspaceSetting.latestView
: state.latestView;
emit(state.copyWith(
workspaceSetting: value.setting,
latestView: latestView,
));
},
unauthorized: (_Unauthorized value) {
emit(state.copyWith(unauthorized: true));
@ -93,12 +107,14 @@ class HomeState with _$HomeState {
const factory HomeState({
required bool isLoading,
required WorkspaceSettingPB workspaceSetting,
ViewPB? latestView,
required bool unauthorized,
}) = _HomeState;
factory HomeState.initial(WorkspaceSettingPB workspaceSetting) => HomeState(
isLoading: false,
workspaceSetting: workspaceSetting,
latestView: null,
unauthorized: false,
);
}

View File

@ -8,7 +8,15 @@ import 'package:dartz/dartz.dart';
part 'settings_dialog_bloc.freezed.dart';
class SettingsDialogBloc extends Bloc<SettingsDialogEvent, SettingsDialogState> {
enum SettingsPage {
appearance,
language,
files,
user,
}
class SettingsDialogBloc
extends Bloc<SettingsDialogEvent, SettingsDialogState> {
final UserListener _userListener;
final UserProfilePB userProfile;
@ -23,8 +31,8 @@ class SettingsDialogBloc extends Bloc<SettingsDialogEvent, SettingsDialogState>
didReceiveUserProfile: (UserProfilePB newUserProfile) {
emit(state.copyWith(userProfile: newUserProfile));
},
setViewIndex: (int viewIndex) {
emit(state.copyWith(viewIndex: viewIndex));
setSelectedPage: (SettingsPage page) {
emit(state.copyWith(page: page));
},
);
});
@ -38,7 +46,8 @@ class SettingsDialogBloc extends Bloc<SettingsDialogEvent, SettingsDialogState>
void _profileUpdated(Either<UserProfilePB, FlowyError> userProfileOrFailed) {
userProfileOrFailed.fold(
(newUserProfile) => add(SettingsDialogEvent.didReceiveUserProfile(newUserProfile)),
(newUserProfile) =>
add(SettingsDialogEvent.didReceiveUserProfile(newUserProfile)),
(err) => Log.error(err),
);
}
@ -47,8 +56,10 @@ class SettingsDialogBloc extends Bloc<SettingsDialogEvent, SettingsDialogState>
@freezed
class SettingsDialogEvent with _$SettingsDialogEvent {
const factory SettingsDialogEvent.initial() = _Initial;
const factory SettingsDialogEvent.didReceiveUserProfile(UserProfilePB newUserProfile) = _DidReceiveUserProfile;
const factory SettingsDialogEvent.setViewIndex(int index) = _SetViewIndex;
const factory SettingsDialogEvent.didReceiveUserProfile(
UserProfilePB newUserProfile) = _DidReceiveUserProfile;
const factory SettingsDialogEvent.setSelectedPage(SettingsPage page) =
_SetViewIndex;
}
@freezed
@ -56,12 +67,13 @@ class SettingsDialogState with _$SettingsDialogState {
const factory SettingsDialogState({
required UserProfilePB userProfile,
required Either<Unit, String> successOrFailure,
required int viewIndex,
required SettingsPage page,
}) = _SettingsDialogState;
factory SettingsDialogState.initial(UserProfilePB userProfile) => SettingsDialogState(
factory SettingsDialogState.initial(UserProfilePB userProfile) =>
SettingsDialogState(
userProfile: userProfile,
successOrFailure: left(unit),
viewIndex: 0,
page: SettingsPage.appearance,
);
}

View File

@ -0,0 +1,75 @@
import 'package:flowy_sdk/protobuf/flowy-folder/app.pb.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class SettingsFileExportState {
SettingsFileExportState({
required this.apps,
}) {
initialize();
}
List<AppPB> apps;
List<bool> expanded = [];
List<bool> selectedApps = [];
List<List<bool>> selectedItems = [];
SettingsFileExportState copyWith({
List<AppPB>? apps,
List<bool>? expanded,
List<bool>? selectedApps,
List<List<bool>>? selectedItems,
}) {
final state = SettingsFileExportState(
apps: apps ?? this.apps,
);
state.expanded = expanded ?? this.expanded;
state.selectedApps = selectedApps ?? this.selectedApps;
state.selectedItems = selectedItems ?? this.selectedItems;
return state;
}
void initialize() {
expanded = apps.map((e) => true).toList();
selectedApps = apps.map((e) => true).toList();
selectedItems =
apps.map((e) => e.belongings.items.map((e) => true).toList()).toList();
}
}
class SettingsFileExporterCubit extends Cubit<SettingsFileExportState> {
SettingsFileExporterCubit({
required List<AppPB> apps,
}) : super(SettingsFileExportState(apps: apps));
void selectOrDeselectItem(int outerIndex, int innerIndex) {
final selectedItems = state.selectedItems;
selectedItems[outerIndex][innerIndex] =
!selectedItems[outerIndex][innerIndex];
emit(state.copyWith(selectedItems: selectedItems));
}
void expandOrUnexpandApp(int outerIndex) {
final expanded = state.expanded;
expanded[outerIndex] = !expanded[outerIndex];
emit(state.copyWith(expanded: expanded));
}
Map<String, List<String>> fetchSelectedPages() {
final apps = state.apps;
final selectedItems = state.selectedItems;
Map<String, List<String>> result = {};
for (var i = 0; i < selectedItems.length; i++) {
final selectedItem = selectedItems[i];
final ids = <String>[];
for (var j = 0; j < selectedItem.length; j++) {
if (selectedItem[j]) {
ids.add(apps[i].belongings.items[j].id);
}
}
if (ids.isNotEmpty) {
result[apps[i].id] = ids;
}
}
return result;
}
}

View File

@ -0,0 +1,57 @@
import 'dart:io';
import 'package:bloc/bloc.dart';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../startup/tasks/prelude.dart';
@visibleForTesting
const String kSettingsLocationDefaultLocation =
'kSettingsLocationDefaultLocation';
class SettingsLocation {
SettingsLocation({
this.path,
});
String? path;
SettingsLocation copyWith({String? path}) {
return SettingsLocation(
path: path ?? this.path,
);
}
}
class SettingsLocationCubit extends Cubit<SettingsLocation> {
SettingsLocationCubit() : super(SettingsLocation(path: null));
/// Returns a path that used to store user data
Future<String> fetchLocation() async {
final prefs = await SharedPreferences.getInstance();
/// Use the [appFlowyDocumentDirectory] instead if there is no user
/// preference location
final path = prefs.getString(kSettingsLocationDefaultLocation) ??
(await appFlowyDocumentDirectory()).path;
emit(state.copyWith(path: path));
return Future.value(path);
}
/// Saves the user preference local data store location
Future<void> setLocation(String? path) async {
path = path ?? (await appFlowyDocumentDirectory()).path;
assert(path.isNotEmpty);
if (path.isEmpty) {
path = (await appFlowyDocumentDirectory()).path;
}
final prefs = await SharedPreferences.getInstance();
prefs.setString(kSettingsLocationDefaultLocation, path);
await Directory(path).create(recursive: true);
emit(state.copyWith(path: path));
}
}

View File

@ -54,13 +54,35 @@ class _HomeScreenState extends State<HomeScreen> {
],
child: HomeHotKeys(
child: Scaffold(
body: BlocListener<HomeBloc, HomeState>(
listenWhen: (p, c) => p.unauthorized != c.unauthorized,
listener: (context, state) {
if (state.unauthorized) {
Log.error("Push to login screen when user token was invalid");
}
},
body: MultiBlocListener(
listeners: [
BlocListener<HomeBloc, HomeState>(
listenWhen: (p, c) => p.unauthorized != c.unauthorized,
listener: (context, state) {
if (state.unauthorized) {
Log.error("Push to login screen when user token was invalid");
}
},
),
BlocListener<HomeBloc, HomeState>(
listenWhen: (p, c) => p.latestView != c.latestView,
listener: (context, state) {
final view = state.latestView;
if (view != null) {
// Only open the last opened view if the [HomeStackManager] current opened plugin is blank and the last opened view is not null.
// All opened widgets that display on the home screen are in the form of plugins. There is a list of built-in plugins defined in the [PluginType] enum, including board, grid and trash.
if (getIt<HomeStackManager>().plugin.ty == PluginType.blank) {
final plugin = makePlugin(
pluginType: view.pluginType,
data: view,
);
getIt<HomeStackManager>().setPlugin(plugin);
getIt<MenuSharedState>().latestOpenView = view;
}
}
},
),
],
child: BlocBuilder<HomeSettingBloc, HomeSettingState>(
buildWhen: (previous, current) => previous != current,
builder: (context, state) {
@ -126,25 +148,6 @@ class _HomeScreenState extends State<HomeScreen> {
collapsedNotifier: getIt<HomeStackManager>().collapsedNotifier,
);
// Only open the last opened view if the [HomeStackManager] current opened
// plugin is blank and the last opened view is not null.
//
// All opened widgets that display on the home screen are in the form
// of plugins. There is a list of built-in plugins defined in the
// [PluginType] enum, including board, grid and trash.
if (getIt<HomeStackManager>().plugin.ty == PluginType.blank) {
// Open the last opened view.
if (workspaceSetting.hasLatestView()) {
final view = workspaceSetting.latestView;
final plugin = makePlugin(
pluginType: view.pluginType,
data: view,
);
getIt<HomeStackManager>().setPlugin(plugin);
getIt<MenuSharedState>().latestOpenView = view;
}
}
return FocusTraversalGroup(child: RepaintBoundary(child: homeMenu));
}

View File

@ -1,6 +1,7 @@
import 'package:app_flowy/startup/startup.dart';
import 'package:app_flowy/generated/locale_keys.g.dart';
import 'package:app_flowy/workspace/presentation/settings/widgets/settings_appearance_view.dart';
import 'package:app_flowy/workspace/presentation/settings/widgets/settings_file_system_view.dart';
import 'package:app_flowy/workspace/presentation/settings/widgets/settings_language_view.dart';
import 'package:app_flowy/workspace/presentation/settings/widgets/settings_user_view.dart';
import 'package:app_flowy/workspace/presentation/settings/widgets/settings_menu.dart';
@ -15,15 +16,6 @@ class SettingsDialog extends StatelessWidget {
final UserProfilePB user;
SettingsDialog(this.user, {Key? key}) : super(key: ValueKey(user.id));
Widget getSettingsView(int index, UserProfilePB user) {
final List<Widget> settingsViews = [
const SettingsAppearanceView(),
const SettingsLanguageView(),
SettingsUserView(user),
];
return settingsViews[index];
}
@override
Widget build(BuildContext context) {
return BlocProvider<SettingsDialogBloc>(
@ -42,20 +34,19 @@ class SettingsDialog extends StatelessWidget {
SizedBox(
width: 200,
child: SettingsMenu(
changeSelectedIndex: (index) {
changeSelectedPage: (index) {
context
.read<SettingsDialogBloc>()
.add(SettingsDialogEvent.setViewIndex(index));
.add(SettingsDialogEvent.setSelectedPage(index));
},
currentIndex:
context.read<SettingsDialogBloc>().state.viewIndex,
currentPage: context.read<SettingsDialogBloc>().state.page,
),
),
const VerticalDivider(),
const SizedBox(width: 10),
Expanded(
child: getSettingsView(
context.read<SettingsDialogBloc>().state.viewIndex,
context.read<SettingsDialogBloc>().state.page,
context.read<SettingsDialogBloc>().state.userProfile,
),
)
@ -65,4 +56,19 @@ class SettingsDialog extends StatelessWidget {
),
);
}
Widget getSettingsView(SettingsPage page, UserProfilePB user) {
switch (page) {
case SettingsPage.appearance:
return const SettingsAppearanceView();
case SettingsPage.language:
return const SettingsLanguageView();
case SettingsPage.files:
return const SettingsFileSystemView();
case SettingsPage.user:
return SettingsUserView(user);
default:
return Container();
}
}
}

View File

@ -0,0 +1,135 @@
import 'package:app_flowy/util/file_picker/file_picker_service.dart';
import 'package:app_flowy/workspace/application/settings/settings_location_cubit.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flutter/services.dart';
import '../../../../generated/locale_keys.g.dart';
import '../../../../main.dart';
import '../../../../startup/launch_configuration.dart';
import '../../../../startup/startup.dart';
import '../../../../startup/tasks/prelude.dart';
import '../../../application/settings/settings_location_cubit.dart';
class SettingsFileLocationCustomzier extends StatefulWidget {
const SettingsFileLocationCustomzier({
super.key,
required this.cubit,
});
final SettingsLocationCubit cubit;
@override
State<SettingsFileLocationCustomzier> createState() =>
SettingsFileLocationCustomzierState();
}
@visibleForTesting
class SettingsFileLocationCustomzierState
extends State<SettingsFileLocationCustomzier> {
@override
Widget build(BuildContext context) {
return BlocProvider<SettingsLocationCubit>.value(
value: widget.cubit,
child: BlocBuilder<SettingsLocationCubit, SettingsLocation>(
builder: (context, state) {
return ListTile(
title: FlowyText.regular(
LocaleKeys.settings_files_defaultLocation.tr(),
fontSize: 15.0,
),
subtitle: Tooltip(
message: LocaleKeys.settings_files_doubleTapToCopy.tr(),
child: GestureDetector(
onDoubleTap: () {
Clipboard.setData(ClipboardData(
text: state.path,
));
},
child: FlowyText.regular(
state.path ?? '',
fontSize: 10.0,
),
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Tooltip(
message: LocaleKeys.settings_files_restoreLocation.tr(),
child: FlowyIconButton(
icon: const Icon(Icons.restore_outlined),
onPressed: () async {
final result = await appFlowyDocumentDirectory();
await _setCustomLocation(result.path);
await FlowyRunner.run(
FlowyApp(),
config: const LaunchConfiguration(
autoRegistrationSupported: true,
),
);
if (mounted) {
Navigator.of(context).pop();
}
},
),
),
const SizedBox(
width: 5,
),
Tooltip(
message: LocaleKeys.settings_files_customizeLocation.tr(),
child: FlowyIconButton(
icon: const Icon(Icons.folder_open_outlined),
onPressed: () async {
final result =
await getIt<FilePickerService>().getDirectoryPath();
if (result != null) {
await _setCustomLocation(result);
await reloadApp();
}
},
),
)
],
),
);
}),
);
}
Future<void> _setCustomLocation(String? path) async {
// Using default location if path equals null.
final location = path ?? (await appFlowyDocumentDirectory()).path;
if (mounted) {
widget.cubit.setLocation(location);
}
// The location could not save into the KV db, because the db initialize is later than the rust sdk initialize.
/*
final prefs = await SharedPreferences.getInstance();
if (mounted) {
context
.read<AppearanceSettingsCubit>()
.setKeyValue(AppearanceKeys.defaultLocation, location);
}
*/
}
Future<void> reloadApp() async {
await FlowyRunner.run(
FlowyApp(),
config: const LaunchConfiguration(
autoRegistrationSupported: true,
),
);
if (mounted) {
Navigator.of(context).pop();
}
return;
}
}

View File

@ -0,0 +1,188 @@
import 'package:app_flowy/startup/startup.dart';
import 'package:app_flowy/util/file_picker/file_picker_service.dart';
import 'package:app_flowy/workspace/application/settings/settings_file_exporter_cubit.dart';
import 'package:dartz/dartz.dart' as dartz;
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flowy_sdk/dispatch/dispatch.dart';
import 'package:flowy_sdk/protobuf/flowy-error/errors.pbserver.dart';
import 'package:flowy_sdk/protobuf/flowy-folder/workspace.pb.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../generated/locale_keys.g.dart';
class FileExporterWidget extends StatefulWidget {
const FileExporterWidget({Key? key}) : super(key: key);
@override
State<FileExporterWidget> createState() => _FileExporterWidgetState();
}
class _FileExporterWidgetState extends State<FileExporterWidget> {
// Map<String, List<String>> _selectedPages = {};
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowyText.medium(
LocaleKeys.settings_files_selectFiles.tr(),
fontSize: 16.0,
),
const VSpace(8),
Expanded(child: _buildFileSelector(context)),
const VSpace(8),
_buildButtons(context)
],
);
}
Row _buildButtons(BuildContext context) {
return Row(
children: [
const Spacer(),
FlowyTextButton(
LocaleKeys.button_Cancel.tr(),
onPressed: () {
Navigator.of(context).pop();
},
),
const HSpace(8),
FlowyTextButton(
LocaleKeys.button_OK.tr(),
onPressed: () async {
// TODO: Export Data
await getIt<FilePickerService>()
.getDirectoryPath()
.then((exportPath) {
Navigator.of(context).pop();
});
},
),
],
);
}
FutureBuilder<dartz.Either<WorkspaceSettingPB, FlowyError>>
_buildFileSelector(BuildContext context) {
return FutureBuilder<dartz.Either<WorkspaceSettingPB, FlowyError>>(
future: FolderEventReadCurrentWorkspace().send(),
builder: (context, snapshot) {
if (snapshot.hasData &&
snapshot.connectionState == ConnectionState.done) {
final workspaces = snapshot.data?.getLeftOrNull<WorkspaceSettingPB>();
if (workspaces != null) {
final apps = workspaces.workspace.apps.items;
return BlocProvider<SettingsFileExporterCubit>(
create: (_) => SettingsFileExporterCubit(apps: apps),
child: const _ExpandedList(),
);
}
}
return const CircularProgressIndicator();
},
);
}
}
class _ExpandedList extends StatefulWidget {
const _ExpandedList({
Key? key,
// required this.apps,
// required this.onChanged,
}) : super(key: key);
// final List<AppPB> apps;
// final void Function(Map<String, List<String>> selectedPages) onChanged;
@override
State<_ExpandedList> createState() => _ExpandedListState();
}
class _ExpandedListState extends State<_ExpandedList> {
@override
Widget build(BuildContext context) {
return BlocBuilder<SettingsFileExporterCubit, SettingsFileExportState>(
builder: (context, state) {
return Material(
color: Colors.transparent,
child: SingleChildScrollView(
child: Column(
children: _buildChildren(context),
),
),
);
},
);
}
List<Widget> _buildChildren(BuildContext context) {
final apps = context.read<SettingsFileExporterCubit>().state.apps;
List<Widget> children = [];
for (var i = 0; i < apps.length; i++) {
children.add(_buildExpandedItem(context, i));
}
return children;
}
Widget _buildExpandedItem(BuildContext context, int index) {
final state = context.read<SettingsFileExporterCubit>().state;
final apps = state.apps;
final expanded = state.expanded;
final selectedItems = state.selectedItems;
final isExpaned = expanded[index] == true;
List<Widget> expandedChildren = [];
if (isExpaned) {
for (var i = 0; i < selectedItems[index].length; i++) {
final name = apps[index].belongings.items[i].name;
final checkbox = CheckboxListTile(
value: selectedItems[index][i],
onChanged: (value) {
// update selected item
context
.read<SettingsFileExporterCubit>()
.selectOrDeselectItem(index, i);
},
title: FlowyText.regular(' $name'),
);
expandedChildren.add(checkbox);
}
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () => context
.read<SettingsFileExporterCubit>()
.expandOrUnexpandApp(index),
child: ListTile(
title: FlowyText.medium(apps[index].name),
trailing: Icon(
isExpaned
? Icons.arrow_drop_down_rounded
: Icons.arrow_drop_up_rounded,
),
),
),
...expandedChildren,
],
);
}
}
extension AppFlowy on dartz.Either {
T? getLeftOrNull<T>() {
if (isLeft()) {
final result = fold<T?>((l) => l, (r) => null);
return result;
}
return null;
}
}

View File

@ -0,0 +1,35 @@
import 'package:app_flowy/workspace/presentation/settings/widgets/settings_file_customize_location_view.dart';
import 'package:flutter/material.dart';
import '../../../application/settings/settings_location_cubit.dart';
class SettingsFileSystemView extends StatefulWidget {
const SettingsFileSystemView({
super.key,
});
@override
State<SettingsFileSystemView> createState() => _SettingsFileSystemViewState();
}
class _SettingsFileSystemViewState extends State<SettingsFileSystemView> {
final _locationCubit = SettingsLocationCubit()..fetchLocation();
@override
Widget build(BuildContext context) {
return ListView.separated(
itemBuilder: (context, index) {
if (index == 0) {
return SettingsFileLocationCustomzier(
cubit: _locationCubit,
);
} else if (index == 1) {
// return _buildExportDatabaseButton();
}
return Container();
},
separatorBuilder: (context, index) => const Divider(),
itemCount: 2, // make the divider taking effect.
);
}
}

View File

@ -1,4 +1,5 @@
import 'package:app_flowy/generated/locale_keys.g.dart';
import 'package:app_flowy/workspace/application/settings/settings_dialog_bloc.dart';
import 'package:app_flowy/workspace/presentation/settings/widgets/settings_menu_element.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@ -6,43 +7,53 @@ import 'package:flutter/material.dart';
class SettingsMenu extends StatelessWidget {
const SettingsMenu({
Key? key,
required this.changeSelectedIndex,
required this.currentIndex,
required this.changeSelectedPage,
required this.currentPage,
}) : super(key: key);
final Function changeSelectedIndex;
final int currentIndex;
final Function changeSelectedPage;
final SettingsPage currentPage;
@override
Widget build(BuildContext context) {
return Column(
children: [
SettingsMenuElement(
index: 0,
currentIndex: currentIndex,
page: SettingsPage.appearance,
selectedPage: currentPage,
label: LocaleKeys.settings_menu_appearance.tr(),
icon: Icons.brightness_4,
changeSelectedIndex: changeSelectedIndex,
changeSelectedPage: changeSelectedPage,
),
const SizedBox(
height: 10,
),
SettingsMenuElement(
index: 1,
currentIndex: currentIndex,
page: SettingsPage.language,
selectedPage: currentPage,
label: LocaleKeys.settings_menu_language.tr(),
icon: Icons.translate,
changeSelectedIndex: changeSelectedIndex,
changeSelectedPage: changeSelectedPage,
),
const SizedBox(
height: 10,
),
SettingsMenuElement(
index: 2,
currentIndex: currentIndex,
page: SettingsPage.files,
selectedPage: currentPage,
label: LocaleKeys.settings_menu_files.tr(),
icon: Icons.file_present_outlined,
changeSelectedPage: changeSelectedPage,
),
const SizedBox(
height: 10,
),
SettingsMenuElement(
page: SettingsPage.user,
selectedPage: currentPage,
label: LocaleKeys.settings_menu_user.tr(),
icon: Icons.account_box_outlined,
changeSelectedIndex: changeSelectedIndex,
changeSelectedPage: changeSelectedPage,
),
],
);

View File

@ -1,3 +1,4 @@
import 'package:app_flowy/workspace/application/settings/settings_dialog_bloc.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
@ -5,18 +6,18 @@ import 'package:flutter/material.dart';
class SettingsMenuElement extends StatelessWidget {
const SettingsMenuElement({
Key? key,
required this.index,
required this.page,
required this.label,
required this.icon,
required this.changeSelectedIndex,
required this.currentIndex,
required this.changeSelectedPage,
required this.selectedPage,
}) : super(key: key);
final int index;
final int currentIndex;
final SettingsPage page;
final SettingsPage selectedPage;
final String label;
final IconData icon;
final Function changeSelectedIndex;
final Function changeSelectedPage;
@override
Widget build(BuildContext context) {
@ -24,14 +25,14 @@ class SettingsMenuElement extends StatelessWidget {
leading: Icon(
icon,
size: 16,
color: index == currentIndex
color: page == selectedPage
? Theme.of(context).colorScheme.onSurface
: Theme.of(context).colorScheme.onSurface,
),
onTap: () {
changeSelectedIndex(index);
changeSelectedPage(page);
},
selected: index == currentIndex,
selected: page == selectedPage,
selectedColor: Theme.of(context).colorScheme.onSurface,
selectedTileColor: Theme.of(context).colorScheme.primaryContainer,
hoverColor: Theme.of(context).colorScheme.primary,

View File

@ -38,7 +38,7 @@ class SettingsUserView extends StatelessWidget {
Widget _renderUserNameInput(BuildContext context) {
String name = context.read<SettingsUserViewBloc>().state.userProfile.name;
return _UserNameInput(name);
return UserNameInput(name);
}
Widget _renderCurrentIcon(BuildContext context) {
@ -51,9 +51,10 @@ class SettingsUserView extends StatelessWidget {
}
}
class _UserNameInput extends StatelessWidget {
@visibleForTesting
class UserNameInput extends StatelessWidget {
final String name;
const _UserNameInput(
const UserNameInput(
this.name, {
Key? key,
}) : super(key: key);

View File

@ -2,13 +2,19 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.temporary-exception.files.absolute-path.read-write</key>
<array>
<string>/</string>
</array>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

View File

@ -2,9 +2,15 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.temporary-exception.files.absolute-path.read-write</key>
<array>
<string>/</string>
</array>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

View File

@ -42,7 +42,6 @@ class AppFlowyPopover extends StatelessWidget {
triggerActions: triggerActions,
popupBuilder: (context) {
final child = popupBuilder(context);
debugPrint('Show $child popover');
return _PopoverContainer(
constraints: constraints,
margin: margin,

View File

@ -274,7 +274,6 @@ class FlowyOverlayState extends State<FlowyOverlay> {
OverlapBehaviour? overlapBehaviour,
FlowyOverlayDelegate? delegate,
}) {
debugPrint("Show overlay: $identifier");
Widget overlay = widget;
final offset = anchorOffset ?? Offset.zero;
final focusNode = FocusNode();

View File

@ -105,6 +105,8 @@ class FlowyTextButton extends StatelessWidget {
final String? tooltip;
final BoxConstraints constraints;
final TextDecoration? decoration;
// final HoverDisplayConfig? hoverDisplay;
const FlowyTextButton(
this.text, {
@ -122,6 +124,7 @@ class FlowyTextButton extends StatelessWidget {
this.mainAxisAlignment = MainAxisAlignment.start,
this.tooltip,
this.constraints = const BoxConstraints(minWidth: 58.0, minHeight: 30.0),
this.decoration,
}) : super(key: key);
@override
@ -139,6 +142,7 @@ class FlowyTextButton extends StatelessWidget {
fontSize: fontSize,
color: fontColor ?? Theme.of(context).colorScheme.onSecondary,
textAlign: TextAlign.center,
decoration: decoration,
),
);

View File

@ -9,6 +9,7 @@ class FlowyText extends StatelessWidget {
final int? maxLines;
final Color? color;
final TextDecoration? decoration;
final bool selectable;
const FlowyText(
this.title, {
@ -20,6 +21,7 @@ class FlowyText extends StatelessWidget {
this.color,
this.maxLines = 1,
this.decoration,
this.selectable = false,
}) : super(key: key);
const FlowyText.regular(
@ -31,6 +33,7 @@ class FlowyText extends StatelessWidget {
this.textAlign,
this.maxLines = 1,
this.decoration,
this.selectable = false,
}) : fontWeight = FontWeight.w400,
super(key: key);
@ -43,6 +46,7 @@ class FlowyText extends StatelessWidget {
this.textAlign,
this.maxLines = 1,
this.decoration,
this.selectable = false,
}) : fontWeight = FontWeight.w500,
super(key: key);
@ -55,22 +59,37 @@ class FlowyText extends StatelessWidget {
this.textAlign,
this.maxLines = 1,
this.decoration,
this.selectable = false,
}) : fontWeight = FontWeight.w600,
super(key: key);
@override
Widget build(BuildContext context) {
return Text(
title,
maxLines: maxLines,
textAlign: textAlign,
overflow: overflow ?? TextOverflow.clip,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: fontSize,
fontWeight: fontWeight,
color: color,
decoration: decoration,
),
);
if (selectable) {
return SelectableText(
title,
maxLines: maxLines,
textAlign: textAlign,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: fontSize,
fontWeight: fontWeight,
color: color,
decoration: decoration,
),
);
} else {
return Text(
title,
maxLines: maxLines,
textAlign: textAlign,
overflow: overflow ?? TextOverflow.clip,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: fontSize,
fontWeight: fontWeight,
color: color,
decoration: decoration,
),
);
}
}
}

View File

@ -23,7 +23,7 @@ class FlowySDK {
return version;
}
const FlowySDK();
FlowySDK();
void dispose() {}

View File

@ -1,3 +1,4 @@
import 'package:app_flowy/startup/launch_configuration.dart';
import 'package:app_flowy/startup/startup.dart';
import 'package:app_flowy/user/application/auth_service.dart';
import 'package:app_flowy/user/application/user_service.dart';
@ -108,7 +109,7 @@ void _pathProviderInitialized() {
class FlowyTestApp implements EntryPoint {
@override
Widget create() {
Widget create(LaunchConfiguration config) {
return Container();
}
}

View File

@ -584,9 +584,10 @@ dependencies = [
"flowy-codegen",
"flowy-derive",
"flowy-sdk",
"lazy_static",
"lib-dispatch",
"log",
"once_cell",
"parking_lot 0.12.1",
"protobuf",
"serde",
"serde_json",
@ -860,7 +861,7 @@ dependencies = [
"diesel_migrations",
"lazy_static",
"lib-sqlite",
"log",
"tracing",
]
[[package]]

View File

@ -21,9 +21,9 @@ log = "0.4.14"
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0" }
bytes = { version = "1.0" }
once_cell = "1"
crossbeam-utils = "0.8.7"
lazy_static = "1.4.0"
parking_lot = "0.12.1"
lib-dispatch = { path = "../lib-dispatch" }
flowy-sdk = { path = "../flowy-sdk" }

View File

@ -10,12 +10,15 @@ use crate::{
};
use flowy_sdk::get_client_server_configuration;
use flowy_sdk::*;
use lazy_static::lazy_static;
use lib_dispatch::prelude::ToBytes;
use lib_dispatch::prelude::*;
use once_cell::sync::OnceCell;
use parking_lot::RwLock;
use std::{ffi::CStr, os::raw::c_char};
static FLOWY_SDK: OnceCell<FlowySDK> = OnceCell::new();
lazy_static! {
static ref FLOWY_SDK: RwLock<Option<FlowySDK>> = RwLock::new(None);
}
#[no_mangle]
pub extern "C" fn init_sdk(path: *mut c_char) -> i64 {
@ -23,8 +26,8 @@ pub extern "C" fn init_sdk(path: *mut c_char) -> i64 {
let path: &str = c_str.to_str().unwrap();
let server_config = get_client_server_configuration().unwrap();
let config = FlowySDKConfig::new(path, "appflowy", server_config).log_filter("info");
FLOWY_SDK.get_or_init(|| FlowySDK::new(config));
let config = FlowySDKConfig::new(path, "appflowy".to_string(), server_config).log_filter("info");
*FLOWY_SDK.write() = Some(FlowySDK::new(config));
0
}
@ -39,7 +42,7 @@ pub extern "C" fn async_event(port: i64, input: *const u8, len: usize) {
port
);
let dispatcher = match FLOWY_SDK.get() {
let dispatcher = match FLOWY_SDK.read().as_ref() {
None => {
log::error!("sdk not init yet.");
return;
@ -57,7 +60,7 @@ pub extern "C" fn sync_event(input: *const u8, len: usize) -> *const u8 {
let request: AFPluginRequest = FFIRequest::from_u8_pointer(input, len).into();
log::trace!("[FFI]: {} Sync Event: {:?}", &request.id, &request.event,);
let dispatcher = match FLOWY_SDK.get() {
let dispatcher = match FLOWY_SDK.read().as_ref() {
None => {
log::error!("sdk not init yet.");
return forget_rust(Vec::default());

View File

@ -10,7 +10,7 @@ diesel = { version = "1.4.8", features = ["sqlite"] }
diesel_derives = { version = "1.4.1", features = ["sqlite"] }
diesel_migrations = { version = "1.4.0", features = ["sqlite"] }
lib-sqlite = { path = "../lib-sqlite" }
log = "0.4"
tracing = { version = "0.1", features = ["log"] }
lazy_static = "1.4.0"
[features]

View File

@ -3,7 +3,7 @@ use ::diesel::{query_dsl::*, ExpressionMethods};
use diesel::{Connection, SqliteConnection};
use lazy_static::lazy_static;
use lib_sqlite::{DBConnection, Database, PoolConfig};
use std::{collections::HashMap, path::Path, sync::RwLock};
use std::{path::Path, sync::RwLock};
macro_rules! impl_get_func {
(
@ -29,7 +29,7 @@ macro_rules! impl_set_func {
match KV::set(item) {
Ok(_) => {}
Err(e) => {
log::error!("{:?}", e)
tracing::error!("{:?}", e)
}
};
}
@ -42,21 +42,15 @@ lazy_static! {
pub struct KV {
database: Option<Database>,
cache: HashMap<String, KeyValue>,
}
impl KV {
fn new() -> Self {
KV {
database: None,
cache: HashMap::new(),
}
KV { database: None }
}
fn set(value: KeyValue) -> Result<(), String> {
log::trace!("[KV]: set value: {:?}", value);
update_cache(value.clone());
// tracing::trace!("[KV]: set value: {:?}", value);
let _ = diesel::replace_into(kv_table::table)
.values(&value)
.execute(&*(get_connection()?))
@ -66,31 +60,18 @@ impl KV {
}
fn get(key: &str) -> Result<KeyValue, String> {
if let Some(value) = read_cache(key) {
return Ok(value);
}
let conn = get_connection()?;
let value = dsl::kv_table
.filter(kv_table::key.eq(key))
.first::<KeyValue>(&*conn)
.map_err(|e| format!("KV get error: {:?}", e))?;
update_cache(value.clone());
Ok(value)
}
#[allow(dead_code)]
pub fn remove(key: &str) -> Result<(), String> {
log::debug!("remove key: {}", key);
match KV_HOLDER.write() {
Ok(mut guard) => {
guard.cache.remove(key);
}
Err(e) => log::error!("Require write lock failed: {:?}", e),
};
// tracing::debug!("remove key: {}", key);
let conn = get_connection()?;
let sql = dsl::kv_table.filter(kv_table::key.eq(key));
let _ = diesel::delete(sql)
@ -99,6 +80,7 @@ impl KV {
Ok(())
}
#[tracing::instrument(level = "trace", err)]
pub fn init(root: &str) -> Result<(), String> {
if !Path::new(root).exists() {
return Err(format!("Init KVStore failed. {} not exists", root));
@ -112,6 +94,7 @@ impl KV {
let mut store = KV_HOLDER
.write()
.map_err(|e| format!("KVStore write failed: {:?}", e))?;
tracing::trace!("Init kv with path: {}", root);
store.database = Some(database);
Ok(())
@ -139,25 +122,6 @@ impl KV {
impl_get_func!(get_float,float_value=>f64);
}
fn read_cache(key: &str) -> Option<KeyValue> {
match KV_HOLDER.read() {
Ok(guard) => guard.cache.get(key).cloned(),
Err(e) => {
log::error!("Require read lock failed: {:?}", e);
None
}
}
}
fn update_cache(value: KeyValue) {
match KV_HOLDER.write() {
Ok(mut guard) => {
guard.cache.insert(value.key.clone(), value);
}
Err(e) => log::error!("Require write lock failed: {:?}", e),
};
}
fn get_connection() -> Result<DBConnection, String> {
match KV_HOLDER.read() {
Ok(store) => {
@ -171,7 +135,7 @@ fn get_connection() -> Result<DBConnection, String> {
}
Err(e) => {
let msg = format!("KVStore get connection failed: {:?}", e);
log::error!("{:?}", msg);
tracing::error!("{:?}", msg);
Err(msg)
}
}

View File

@ -22,6 +22,7 @@ use folder_rev_model::user_default;
use lazy_static::lazy_static;
use lib_infra::future::FutureResult;
use crate::services::clear_current_workspace;
use crate::services::persistence::rev_sqlite::SQLiteFolderRevisionPersistence;
use flowy_http_model::ws_data::ServerRevisionWSData;
use flowy_sync::client_folder::FolderPad;
@ -206,7 +207,11 @@ impl FolderManager {
self.initialize(user_id, token).await
}
pub async fn clear(&self) {
/// Called when the current user logout
///
pub async fn clear(&self, user_id: &str) {
self.view_controller.clear_latest_view();
clear_current_workspace(user_id);
*self.folder_editor.write().await = None;
}
}
@ -220,9 +225,9 @@ impl DefaultFolderBuilder {
view_controller: Arc<ViewController>,
create_view_fn: F,
) -> FlowyResult<()> {
log::debug!("Create user default workspace");
let workspace_rev = user_default::create_default_workspace();
set_current_workspace(&workspace_rev.id);
tracing::debug!("Create user:{} default workspace:{}", user_id, workspace_rev.id);
set_current_workspace(user_id, &workspace_rev.id);
for app in workspace_rev.apps.iter() {
for (index, view) in app.belongings.iter().enumerate() {
let (view_data_type, view_data) = create_view_fn();

View File

@ -19,7 +19,6 @@ use std::sync::Arc;
const V1_MIGRATION: &str = "FOLDER_V1_MIGRATION";
const V2_MIGRATION: &str = "FOLDER_V2_MIGRATION";
#[allow(dead_code)]
const V3_MIGRATION: &str = "FOLDER_V3_MIGRATION";
pub(crate) struct FolderMigration {

View File

@ -188,6 +188,11 @@ impl ViewController {
Ok(())
}
#[tracing::instrument(level = "trace", skip(self))]
pub(crate) fn clear_latest_view(&self) {
let _ = KV::remove(LATEST_VIEW_ID);
}
#[tracing::instrument(level = "debug", skip(self), err)]
pub(crate) async fn close_view(&self, view_id: &str) -> Result<(), FlowyError> {
let processor = self.get_data_processor_from_view_id(view_id).await?;

View File

@ -56,7 +56,7 @@ impl WorkspaceController {
send_dart_notification(&token, FolderNotification::UserCreateWorkspace)
.payload(repeated_workspace)
.send();
set_current_workspace(&workspace.id);
set_current_workspace(&user_id, &workspace.id);
Ok(workspace)
}
@ -106,7 +106,7 @@ impl WorkspaceController {
.persistence
.begin_transaction(|transaction| self.read_local_workspace(workspace_id, &user_id, &transaction))
.await?;
set_current_workspace(&workspace.id);
set_current_workspace(&user_id, &workspace.id);
Ok(workspace)
} else {
Err(FlowyError::workspace_id().context("Opened workspace id should not be empty"))
@ -114,7 +114,8 @@ impl WorkspaceController {
}
pub(crate) async fn read_current_workspace_apps(&self) -> Result<Vec<AppRevision>, FlowyError> {
let workspace_id = get_current_workspace()?;
let user_id = self.user.user_id()?;
let workspace_id = get_current_workspace(&user_id)?;
let app_revs = self
.persistence
.begin_transaction(|transaction| {
@ -209,7 +210,7 @@ pub async fn notify_workspace_setting_did_change(
) -> FlowyResult<()> {
let user_id = folder_manager.user.user_id()?;
let token = folder_manager.user.token()?;
let workspace_id = get_current_workspace()?;
let workspace_id = get_current_workspace(&user_id)?;
let workspace_setting = folder_manager
.persistence
@ -243,11 +244,15 @@ pub async fn notify_workspace_setting_did_change(
const CURRENT_WORKSPACE_ID: &str = "current_workspace_id";
pub fn set_current_workspace(workspace_id: &str) {
pub fn set_current_workspace(_user_id: &str, workspace_id: &str) {
KV::set_str(CURRENT_WORKSPACE_ID, workspace_id.to_owned());
}
pub fn get_current_workspace() -> Result<String, FlowyError> {
pub fn clear_current_workspace(_user_id: &str) {
let _ = KV::remove(CURRENT_WORKSPACE_ID);
}
pub fn get_current_workspace(_user_id: &str) -> Result<String, FlowyError> {
match KV::get_str(CURRENT_WORKSPACE_ID) {
None => {
Err(FlowyError::record_not_found()

View File

@ -80,8 +80,8 @@ pub(crate) async fn read_workspaces_handler(
pub async fn read_cur_workspace_handler(
folder: AFPluginState<Arc<FolderManager>>,
) -> DataResult<WorkspaceSettingPB, FlowyError> {
let workspace_id = get_current_workspace()?;
let user_id = folder.user.user_id()?;
let workspace_id = get_current_workspace(&user_id)?;
let params = WorkspaceIdPB {
value: Some(workspace_id.clone()),
};

View File

@ -35,7 +35,9 @@ static INIT_LOG: AtomicBool = AtomicBool::new(false);
#[derive(Clone)]
pub struct FlowySDKConfig {
/// Different `FlowySDK` instance should have different name
name: String,
/// Panics if the `root` path is not existing
root: String,
log_filter: String,
server_config: ClientServerConfiguration,
@ -53,9 +55,9 @@ impl fmt::Debug for FlowySDKConfig {
}
impl FlowySDKConfig {
pub fn new(root: &str, name: &str, server_config: ClientServerConfiguration) -> Self {
pub fn new(root: &str, name: String, server_config: ClientServerConfiguration) -> Self {
FlowySDKConfig {
name: name.to_owned(),
name,
root: root.to_owned(),
log_filter: crate_log_filter("info".to_owned()),
server_config,
@ -93,7 +95,7 @@ fn crate_log_filter(level: String) -> String {
// filters.push(format!("lib_dispatch={}", level));
filters.push(format!("dart_ffi={}", "info"));
filters.push(format!("flowy_database={}", "info"));
filters.push(format!("flowy_database={}", level));
filters.push(format!("flowy_net={}", "info"));
filters.join(",")
}
@ -268,14 +270,14 @@ async fn _listen_user_status(
let _ = grid_manager.initialize(&user_id, &token).await?;
let _ = ws_conn.start(token, user_id).await?;
}
UserStatus::Logout { .. } => {
UserStatus::Logout { token: _, user_id } => {
tracing::trace!("User did logout");
folder_manager.clear().await;
folder_manager.clear(&user_id).await;
let _ = ws_conn.stop().await;
}
UserStatus::Expired { .. } => {
UserStatus::Expired { token: _, user_id } => {
tracing::trace!("User session has been expired");
folder_manager.clear().await;
folder_manager.clear(&user_id).await;
let _ = ws_conn.stop().await;
}
UserStatus::SignUp { profile, ret } => {
@ -338,8 +340,7 @@ fn mk_user_session(
local_server: &Option<Arc<LocalServer>>,
server_config: &ClientServerConfiguration,
) -> Arc<UserSession> {
let session_cache_key = format!("{}_session_cache", &config.name);
let user_config = UserSessionConfig::new(&config.root, &session_cache_key);
let user_config = UserSessionConfig::new(&config.name, &config.root);
let cloud_service = UserDepsResolver::resolve(local_server, server_config);
Arc::new(UserSession::new(user_config, cloud_service))
}

View File

@ -36,7 +36,7 @@ impl std::default::Default for FlowySDKTest {
impl FlowySDKTest {
pub fn new(document_version: DocumentVersionPB) -> Self {
let server_config = get_client_server_configuration().unwrap();
let config = FlowySDKConfig::new(&root_dir(), &nanoid!(6), server_config)
let config = FlowySDKConfig::new(&root_dir(), nanoid!(6), server_config)
.with_document_version(document_version)
.log_filter("info");
let sdk = std::thread::spawn(|| FlowySDK::new(config)).join().unwrap();

View File

@ -6,10 +6,6 @@ use lazy_static::lazy_static;
use parking_lot::RwLock;
use std::{collections::HashMap, sync::Arc, time::Duration};
lazy_static! {
static ref DB: RwLock<Option<Database>> = RwLock::new(None);
}
pub struct UserDB {
db_dir: String,
}
@ -21,6 +17,7 @@ impl UserDB {
}
}
#[tracing::instrument(level = "trace", skip(self))]
fn open_user_db_if_need(&self, user_id: &str) -> Result<Arc<ConnectionPool>, FlowyError> {
if user_id.is_empty() {
return Err(ErrorCode::UserIdIsEmpty.into());

View File

@ -9,9 +9,11 @@ pub enum UserStatus {
},
Logout {
token: String,
user_id: String,
},
Expired {
token: String,
user_id: String,
},
SignUp {
profile: UserProfilePB,
@ -49,9 +51,10 @@ impl UserNotifier {
});
}
pub(crate) fn notify_logout(&self, token: &str) {
pub(crate) fn notify_logout(&self, token: &str, user_id: &str) {
let _ = self.user_status_notifier.send(UserStatus::Logout {
token: token.to_owned(),
user_id: user_id.to_owned(),
});
}

View File

@ -17,21 +17,25 @@ use flowy_database::{
schema::{user_table, user_table::dsl},
DBConnection, ExpressionMethods, UserDatabaseConnection,
};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::mpsc;
pub struct UserSessionConfig {
root_dir: String,
/// Used as the key of `Session` when saving session information to KV.
session_cache_key: String,
}
impl UserSessionConfig {
pub fn new(root_dir: &str, session_cache_key: &str) -> Self {
/// The `root_dir` represents as the root of the user folders. It must be unique for each
/// users.
pub fn new(name: &str, root_dir: &str) -> Self {
let session_cache_key = format!("{}_session_cache", name);
Self {
root_dir: root_dir.to_owned(),
session_cache_key: session_cache_key.to_owned(),
session_cache_key,
}
}
}
@ -40,7 +44,6 @@ pub struct UserSession {
database: UserDB,
config: UserSessionConfig,
cloud_service: Arc<dyn UserCloudService>,
session: RwLock<Option<Session>>,
pub notifier: UserNotifier,
}
@ -52,7 +55,6 @@ impl UserSession {
database: db,
config,
cloud_service,
session: RwLock::new(None),
notifier,
}
}
@ -119,7 +121,7 @@ impl UserSession {
diesel::delete(dsl::user_table.filter(dsl::id.eq(&session.user_id))).execute(&*(self.db_connection()?))?;
let _ = self.database.close_user_db(&session.user_id)?;
let _ = self.set_session(None)?;
self.notifier.notify_logout(&session.token);
self.notifier.notify_logout(&session.token, &session.user_id);
let _ = self.sign_out_on_server(&session.token).await?;
Ok(())
@ -253,25 +255,16 @@ impl UserSession {
None => KV::remove(&self.config.session_cache_key).map_err(|e| FlowyError::new(ErrorCode::Internal, &e))?,
Some(session) => KV::set_str(&self.config.session_cache_key, session.clone().into()),
}
*self.session.write() = session;
Ok(())
}
fn get_session(&self) -> Result<Session, FlowyError> {
let mut session = { (*self.session.read()).clone() };
if session.is_none() {
match KV::get_str(&self.config.session_cache_key) {
None => {}
Some(s) => {
session = Some(Session::from(s));
let _ = self.set_session(session.clone())?;
}
}
}
match session {
match KV::get_str(&self.config.session_cache_key) {
None => Err(FlowyError::unauthorized()),
Some(session) => Ok(session),
Some(s) => {
tracing::debug!("Get user session: {:?}", s);
Ok(Session::from(s))
}
}
}