1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-08-10 22:11:50 +02:00

Mobile: Add support for scanning multi-page documents (#12635)

This commit is contained in:
Henry Heino
2025-07-18 06:33:58 -07:00
committed by GitHub
parent 6c5293833d
commit 0a6b8fb90a
27 changed files with 792 additions and 136 deletions

View File

@@ -625,8 +625,10 @@ packages/app-mobile/components/CameraView/Camera/index.web.js
packages/app-mobile/components/CameraView/Camera/types.js
packages/app-mobile/components/CameraView/CameraView.test.js
packages/app-mobile/components/CameraView/CameraView.js
packages/app-mobile/components/CameraView/CameraView.web.js
packages/app-mobile/components/CameraView/CameraViewMultiPage.test.js
packages/app-mobile/components/CameraView/CameraViewMultiPage.js
packages/app-mobile/components/CameraView/PhotoPreview.js
packages/app-mobile/components/CameraView/ScannedBarcodes.js
packages/app-mobile/components/CameraView/types.js
packages/app-mobile/components/CameraView/utils/fitRectIntoBounds.js
@@ -637,6 +639,7 @@ packages/app-mobile/components/ComboBox.test.js
packages/app-mobile/components/ComboBox.js
packages/app-mobile/components/DialogManager/PromptButton.js
packages/app-mobile/components/DialogManager/PromptDialog.js
packages/app-mobile/components/DialogManager/TextInputDialog.js
packages/app-mobile/components/DialogManager/hooks/useDialogControl.js
packages/app-mobile/components/DialogManager/index.js
packages/app-mobile/components/DialogManager/types.js
@@ -815,6 +818,8 @@ packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginItem.
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useRepoApi.js
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useUpdateState.js
packages/app-mobile/components/screens/ConfigScreen/types.js
packages/app-mobile/components/screens/DocumentScanner/DocumentScanner.js
packages/app-mobile/components/screens/DocumentScanner/NotePreview.js
packages/app-mobile/components/screens/JoplinCloudLoginScreen.js
packages/app-mobile/components/screens/LogScreen.js
packages/app-mobile/components/screens/Note/Note.test.js

5
.gitignore vendored
View File

@@ -600,8 +600,10 @@ packages/app-mobile/components/CameraView/Camera/index.web.js
packages/app-mobile/components/CameraView/Camera/types.js
packages/app-mobile/components/CameraView/CameraView.test.js
packages/app-mobile/components/CameraView/CameraView.js
packages/app-mobile/components/CameraView/CameraView.web.js
packages/app-mobile/components/CameraView/CameraViewMultiPage.test.js
packages/app-mobile/components/CameraView/CameraViewMultiPage.js
packages/app-mobile/components/CameraView/PhotoPreview.js
packages/app-mobile/components/CameraView/ScannedBarcodes.js
packages/app-mobile/components/CameraView/types.js
packages/app-mobile/components/CameraView/utils/fitRectIntoBounds.js
@@ -612,6 +614,7 @@ packages/app-mobile/components/ComboBox.test.js
packages/app-mobile/components/ComboBox.js
packages/app-mobile/components/DialogManager/PromptButton.js
packages/app-mobile/components/DialogManager/PromptDialog.js
packages/app-mobile/components/DialogManager/TextInputDialog.js
packages/app-mobile/components/DialogManager/hooks/useDialogControl.js
packages/app-mobile/components/DialogManager/index.js
packages/app-mobile/components/DialogManager/types.js
@@ -790,6 +793,8 @@ packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginItem.
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useRepoApi.js
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useUpdateState.js
packages/app-mobile/components/screens/ConfigScreen/types.js
packages/app-mobile/components/screens/DocumentScanner/DocumentScanner.js
packages/app-mobile/components/screens/DocumentScanner/NotePreview.js
packages/app-mobile/components/screens/JoplinCloudLoginScreen.js
packages/app-mobile/components/screens/LogScreen.js
packages/app-mobile/components/screens/Note/Note.test.js

View File

@@ -15,6 +15,7 @@ const Camera = (props: Props, ref: ForwardedRef<CameraRef>) => {
await shim.fsDriver().writeFile(
path,
`<svg viewBox="0 -70 232 78" width="232" height="78" version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
<rect width="232" height="78" y="-70" rx="32" style="fill: #ccc;"/>
<text style="font-family: serif; font-size: 104px; fill: rgb(128, 51, 128);">Test!</text>
</svg>`,
'utf8',

View File

@@ -16,24 +16,12 @@ import useBarcodeScanner from './utils/useBarcodeScanner';
import ScannedBarcodes from './ScannedBarcodes';
import { CameraRef } from './Camera/types';
import Camera from './Camera/index';
import { CameraResult, OnInsertBarcode } from './types';
import { CameraViewProps } from './types';
import Logger from '@joplin/utils/Logger';
import useBackHandler from '../../utils/hooks/useBackHandler';
const logger = Logger.create('CameraView');
interface Props {
themeId: number;
style: ViewStyle;
cameraType: CameraDirection;
cameraRatio: string;
onPhoto: (data: CameraResult)=> void;
// If null, cancelling should be handled by the parent
// component
onCancel: (()=> void)|null;
onInsertBarcode: OnInsertBarcode;
}
interface UseStyleProps {
themeId: number;
style: ViewStyle;
@@ -104,7 +92,7 @@ const useAvailableRatios = (): string[] => {
};
const CameraViewComponent: React.FC<Props> = props => {
const CameraViewComponent: React.FC<CameraViewProps> = props => {
const styles = useStyles(props);
const cameraRef = useRef<CameraRef|null>(null);
const [cameraReady, setCameraReady] = useState(false);

View File

@@ -0,0 +1,58 @@
import * as React from 'react';
import { useCallback, useMemo } from 'react';
import { connect } from 'react-redux';
import { StyleSheet, View } from 'react-native';
import { _ } from '@joplin/lib/locale';
import { AppState } from '../../utils/types';
import { PrimaryButton } from '../buttons';
import { themeStyle } from '../global-style';
import { CameraViewProps } from './types';
import pickDocument from '../../utils/pickDocument';
const useStyles = (themeId: number) => {
return useMemo(() => {
const theme = themeStyle(themeId);
return StyleSheet.create({
container: {
backgroundColor: theme.backgroundColor,
flexGrow: 1,
justifyContent: 'center',
alignItems: 'center',
},
});
}, [themeId]);
};
const CameraViewComponent: React.FC<CameraViewProps> = props => {
const styles = useStyles(props.themeId);
const onUploadPress = useCallback(async () => {
const response = await pickDocument({ preferCamera: true });
for (const asset of response) {
props.onPhoto({
uri: asset.uri,
type: asset.type,
});
}
}, [props.onPhoto]);
return (
<View style={styles.container}>
<PrimaryButton
icon='file-upload'
onPress={onUploadPress}
>{_('Upload photo')}</PrimaryButton>
</View>
);
};
const mapStateToProps = (state: AppState) => {
return {
themeId: state.settings.theme,
cameraRatio: state.settings['camera.ratio'],
cameraType: state.settings['camera.type'],
};
};
export default connect(mapStateToProps)(CameraViewComponent);

View File

@@ -1,6 +1,6 @@
import * as React from 'react';
import Setting from '@joplin/lib/models/Setting';
import CameraViewMultiPage, { OnComplete } from './CameraViewMultiPage';
import CameraViewMultiPage from './CameraViewMultiPage';
import { CameraResult, OnInsertBarcode } from './types';
import { Store } from 'redux';
import { AppState } from '../../utils/types';
@@ -8,24 +8,29 @@ import TestProviderStack from '../testing/TestProviderStack';
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
import { fireEvent, render, screen, waitFor } from '@testing-library/react-native';
import { startCamera, takePhoto } from './utils/testing';
import { useState } from 'react';
interface WrapperProps {
onComplete?: (finalPhotos: CameraResult[])=> void;
onCancel?: ()=> void;
onInsertBarcode?: OnInsertBarcode;
onComplete?: OnComplete;
}
let store: Store<AppState>;
const WrappedCamera: React.FC<WrapperProps> = ({
onCancel = jest.fn(),
onComplete = jest.fn(),
onInsertBarcode = jest.fn(),
onCancel = jest.fn(),
}) => {
const [photos, setPhotos] = useState<CameraResult[]>([]);
return <TestProviderStack store={store}>
<CameraViewMultiPage
themeId={Setting.THEME_LIGHT}
photos={photos}
onSetPhotos={setPhotos}
onCancel={onCancel}
onComplete={onComplete}
onComplete={() => onComplete(photos)}
onInsertBarcode={onInsertBarcode}
/>
</TestProviderStack>;

View File

@@ -1,20 +1,22 @@
import * as React from 'react';
import { CameraResult } from './types';
import { View, StyleSheet, Platform, ImageBackground, ViewStyle, TextStyle } from 'react-native';
import { View, StyleSheet } from 'react-native';
import CameraView from './CameraView';
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useMemo } from 'react';
import { themeStyle } from '../global-style';
import { Button, Text } from 'react-native-paper';
import { Button } from 'react-native-paper';
import { _ } from '@joplin/lib/locale';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import shim from '@joplin/lib/shim';
import PhotoPreview from './PhotoPreview';
export type OnComplete = (photos: CameraResult[])=> void;
export type OnPhotosChange = (photos: CameraResult[])=> void;
export type OnComplete = ()=> void;
interface Props {
themeId: number;
onCancel: ()=> void;
onComplete: OnComplete;
photos: CameraResult[];
onSetPhotos: OnPhotosChange;
onInsertBarcode: (barcodeText: string)=> void;
}
@@ -42,17 +44,8 @@ const useStyle = (themeId: number) => {
imagePreview: {
maxWidth: 70,
flexShrink: 1,
flexGrow: 1,
alignContent: 'center',
justifyContent: 'center',
},
imageCountText: {
marginLeft: 'auto',
marginRight: 'auto',
marginTop: 'auto',
padding: 2,
borderRadius: 4,
backgroundColor: theme.backgroundColor2,
color: theme.color2,
},
@@ -60,55 +53,13 @@ const useStyle = (themeId: number) => {
}, [themeId]);
};
interface PhotoProps {
source: CameraResult;
backgroundStyle: ViewStyle;
textStyle: TextStyle;
label: number;
}
const PhotoPreview: React.FC<PhotoProps> = ({ source, label, backgroundStyle, textStyle }) => {
const [uri, setUri] = useState('');
useAsyncEffect(async (event) => {
if (Platform.OS === 'web') {
const file = await shim.fsDriver().fileAtPath(source.uri);
if (event.cancelled) return;
const uri = URL.createObjectURL(file);
setUri(uri);
event.onCleanup(() => {
URL.revokeObjectURL(uri);
});
} else {
setUri(source.uri);
}
}, [source]);
return <ImageBackground
style={backgroundStyle}
resizeMode='contain'
source={{ uri }}
accessibilityLabel={_('%d photo(s) taken', label)}
>
<Text
style={textStyle}
testID='photo-count'
>{label}</Text>
</ImageBackground>;
};
const CameraViewMultiPage: React.FC<Props> = ({
onInsertBarcode, onCancel, onComplete, themeId,
onInsertBarcode, onCancel, onComplete, themeId, photos, onSetPhotos,
}) => {
const [photos, setPhotos] = useState<CameraResult[]>([]);
const onPhoto = useCallback((data: CameraResult) => {
setPhotos(photos => [...photos, data]);
}, []);
const onDonePressed = useCallback(() => {
onComplete(photos);
}, [photos, onComplete]);
onSetPhotos([...photos, data]);
}, [photos, onSetPhotos]);
const styles = useStyle(themeId);
const renderLastPhoto = () => {
@@ -137,7 +88,7 @@ const CameraViewMultiPage: React.FC<Props> = ({
<Button
icon='arrow-right'
disabled={photos.length === 0}
onPress={onDonePressed}
onPress={onComplete}
>{_('Next')}</Button>
</View>
</View>;

View File

@@ -0,0 +1,69 @@
import * as React from 'react';
import { ViewStyle, TextStyle, Platform, ImageBackground, Text, StyleSheet } from 'react-native';
import { useState } from 'react';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import { CameraResult } from './types';
import shim from '@joplin/lib/shim';
import { _ } from '@joplin/lib/locale';
interface PhotoProps {
source: CameraResult;
backgroundStyle: ViewStyle;
textStyle: TextStyle;
label: number;
}
const styles = StyleSheet.create({
background: {
maxWidth: 70,
flexShrink: 1,
flexGrow: 1,
alignContent: 'center',
justifyContent: 'center',
},
text: {
marginLeft: 'auto',
marginRight: 'auto',
marginTop: 'auto',
padding: 4,
borderRadius: 32,
color: 'white',
backgroundColor: '#11c',
},
});
const PhotoPreview: React.FC<PhotoProps> = ({ source, label, backgroundStyle, textStyle }) => {
const [uri, setUri] = useState('');
useAsyncEffect(async (event) => {
if (!source) {
setUri('');
} else if (Platform.OS === 'web') {
const file = await shim.fsDriver().fileAtPath(source.uri);
if (event.cancelled) return;
const uri = URL.createObjectURL(file);
setUri(uri);
event.onCleanup(() => {
URL.revokeObjectURL(uri);
});
} else {
setUri(source.uri);
}
}, [source]);
return <ImageBackground
style={[styles.background, backgroundStyle]}
resizeMode='contain'
source={{ uri }}
accessibilityLabel={_('%d photo(s) taken', label)}
>
<Text
style={[styles.text, textStyle]}
testID='photo-count'
>{label}</Text>
</ImageBackground>;
};
export default PhotoPreview;

View File

@@ -1,3 +1,5 @@
import type { CameraDirection } from '@joplin/lib/models/settings/builtInMetadata';
import type { ViewStyle } from 'react-native';
export type OnInsertBarcode = (barcodeText: string)=> void;
@@ -5,3 +7,15 @@ export interface CameraResult {
uri: string;
type: string;
}
export interface CameraViewProps {
themeId: number;
style: ViewStyle;
cameraType: CameraDirection;
cameraRatio: string;
onPhoto: (data: CameraResult)=> void;
// If null, cancelling should be handled by the parent
// component
onCancel: (()=> void)|null;
onInsertBarcode: OnInsertBarcode;
}

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { AccessibilityInfo, NativeSyntheticEvent, Platform, Role, StyleSheet, TextInput, TextInputProps, useWindowDimensions, View, ViewProps, ViewStyle } from 'react-native';
import { AccessibilityInfo, NativeSyntheticEvent, Platform, Role, ScrollViewProps, StyleSheet, TextInput, TextInputProps, useWindowDimensions, View, ViewProps, ViewStyle } from 'react-native';
import { TouchableRipple, Text } from 'react-native-paper';
import { connect } from 'react-redux';
import { AppState } from '../utils/types';
@@ -35,6 +35,7 @@ interface BaseProps {
onItemSelected: OnItemSelected;
style: ViewStyle;
searchInputProps?: TextInputProps;
searchResultProps?: ScrollViewProps;
}
type OnAddItem = (content: string)=> void;
@@ -478,6 +479,7 @@ const ComboBox: React.FC<Props> = ({
style: rootStyle,
alwaysExpand,
searchInputProps,
searchResultProps,
}) => {
const [search, setSearch] = useState('');
const { showSearchResults, setShowSearchResults } = useShowSearchResults(alwaysExpand, search);
@@ -540,6 +542,7 @@ const ComboBox: React.FC<Props> = ({
const searchResults = <NestableFlatList
ref={listRef}
data={results}
{...searchResultProps}
CellRendererComponent={SearchResultWrapper}
itemHeight={menuItemHeight}

View File

@@ -1,28 +1,19 @@
import * as React from 'react';
import { Dialog, Divider, Surface, Text } from 'react-native-paper';
import { DialogType, PromptDialogData } from './types';
import { StyleSheet } from 'react-native';
import { DialogType, ButtonDialogData } from './types';
import { StyleSheet, ViewStyle } from 'react-native';
import { useMemo } from 'react';
import { themeStyle } from '../global-style';
import PromptButton from './PromptButton';
interface Props {
dialog: PromptDialogData;
dialog: ButtonDialogData;
containerStyle: ViewStyle;
themeId: number;
}
const useStyles = (themeId: number, isMenu: boolean) => {
const useStyles = (isMenu: boolean) => {
return useMemo(() => {
const theme = themeStyle(themeId);
return StyleSheet.create({
dialogContainer: {
backgroundColor: theme.backgroundColor,
borderRadius: theme.borderRadius,
paddingTop: theme.borderRadius,
marginLeft: 4,
marginRight: 4,
},
dialogContent: {
paddingBottom: 14,
@@ -40,12 +31,12 @@ const useStyles = (themeId: number, isMenu: boolean) => {
textAlign: isMenu ? 'center' : undefined,
},
});
}, [themeId, isMenu]);
}, [isMenu]);
};
const PromptDialog: React.FC<Props> = ({ dialog, themeId }) => {
const PromptDialog: React.FC<Props> = ({ dialog, containerStyle, themeId }) => {
const isMenu = dialog.type === DialogType.Menu;
const styles = useStyles(themeId, isMenu);
const styles = useStyles(isMenu);
const buttons = dialog.buttons.map((button, index) => {
return <PromptButton
@@ -63,7 +54,7 @@ const PromptDialog: React.FC<Props> = ({ dialog, themeId }) => {
return (
<Surface
testID={'prompt-dialog'}
style={styles.dialogContainer}
style={containerStyle}
key={dialog.key}
elevation={1}
>

View File

@@ -0,0 +1,76 @@
import * as React from 'react';
import { Dialog, Surface, Text } from 'react-native-paper';
import { TextInputDialogData } from './types';
import { StyleSheet, ViewStyle } from 'react-native';
import { useId, useMemo, useState } from 'react';
import PromptButton from './PromptButton';
import { _ } from '@joplin/lib/locale';
import TextInput from '../TextInput';
interface Props {
dialog: TextInputDialogData;
containerStyle: ViewStyle;
themeId: number;
}
const useStyles = () => {
return useMemo(() => {
return StyleSheet.create({
dialogContent: {
paddingBottom: 14,
},
dialogActions: {
paddingBottom: 14,
paddingTop: 4,
},
});
}, []);
};
const TextInputDialog: React.FC<Props> = ({ dialog, containerStyle, themeId }) => {
const styles = useStyles();
const [text, setText] = useState('');
const labelId = useId();
return (
<Surface
testID={'prompt-dialog'}
style={containerStyle}
key={dialog.key}
elevation={1}
>
<Dialog.Content style={styles.dialogContent}>
<Text
variant='bodyMedium'
nativeID={labelId}
>{dialog.message}</Text>
<TextInput
aria-labelledby={labelId}
themeId={themeId}
value={text}
onChangeText={setText}
/>
</Dialog.Content>
<Dialog.Actions
style={styles.dialogActions}
>
<PromptButton
buttonSpec={{
text: _('Cancel'),
onPress: dialog.onDismiss,
}}
themeId={themeId}
/>
<PromptButton
buttonSpec={{
text: _('Okay'),
onPress: () => dialog.onSubmit(text),
}}
themeId={themeId}
/>
</Dialog.Actions>
</Surface>
);
};
export default TextInputDialog;

View File

@@ -1,16 +1,16 @@
import * as React from 'react';
import { Alert, Platform } from 'react-native';
import { DialogControl, DialogType, MenuChoice, PromptButtonSpec, PromptDialogData, PromptOptions } from '../types';
import { DialogControl, DialogType, MenuChoice, PromptButtonSpec, DialogData, PromptOptions } from '../types';
import { _ } from '@joplin/lib/locale';
import { useMemo, useRef } from 'react';
type SetPromptDialogs = React.Dispatch<React.SetStateAction<PromptDialogData[]>>;
type SetPromptDialogs = React.Dispatch<React.SetStateAction<DialogData[]>>;
const useDialogControl = (setPromptDialogs: SetPromptDialogs) => {
const nextDialogIdRef = useRef(0);
const dialogControl: DialogControl = useMemo(() => {
const onDismiss = (dialog: PromptDialogData) => {
const onDismiss = (dialog: DialogData) => {
setPromptDialogs(dialogs => dialogs.filter(d => d !== dialog));
};
@@ -39,8 +39,8 @@ const useDialogControl = (setPromptDialogs: SetPromptDialogs) => {
Alert.alert(title, message, buttons, options);
} else {
const cancelable = options?.cancelable ?? true;
const dialog: PromptDialogData = {
type: DialogType.Prompt,
const dialog: DialogData = {
type: DialogType.ButtonPrompt,
key: `dialog-${nextDialogIdRef.current++}`,
title,
message,
@@ -69,7 +69,7 @@ const useDialogControl = (setPromptDialogs: SetPromptDialogs) => {
return new Promise<T>((resolve) => {
const dismiss = () => onDismiss(dialog);
const dialog: PromptDialogData = {
const dialog: DialogData = {
type: DialogType.Menu,
key: `menu-dialog-${nextDialogIdRef.current++}`,
title: '',
@@ -91,6 +91,33 @@ const useDialogControl = (setPromptDialogs: SetPromptDialogs) => {
});
});
},
promptForText: (message: string) => {
return new Promise<string|null>((resolve) => {
const dismiss = () => {
onDismiss(dialog);
};
const dialog: DialogData = {
type: DialogType.TextInput,
key: `prompt-dialog-${nextDialogIdRef.current++}`,
message,
onSubmit: (text) => {
resolve(text);
dismiss();
},
onDismiss: () => {
resolve(null);
dismiss();
},
};
setPromptDialogs(dialogs => {
return [
...dialogs,
dialog,
];
});
});
},
};
return control;

View File

@@ -5,10 +5,11 @@ import { Portal } from 'react-native-paper';
import Modal from '../Modal';
import shim from '@joplin/lib/shim';
import makeShowMessageBox from '../../utils/makeShowMessageBox';
import { DialogControl, PromptDialogData } from './types';
import { DialogControl, DialogData, DialogType } from './types';
import useDialogControl from './hooks/useDialogControl';
import PromptDialog from './PromptDialog';
import { themeStyle } from '../global-style';
import TextInputDialog from './TextInputDialog';
export type { DialogControl } from './types';
export const DialogContext = createContext<DialogControl>(null);
@@ -18,10 +19,11 @@ interface Props {
children: React.ReactNode;
}
const useStyles = () => {
const useStyles = (themeId: number) => {
const windowSize = useWindowDimensions();
return useMemo(() => {
const theme = themeStyle(themeId);
return StyleSheet.create({
modalContainer: {
marginLeft: 'auto',
@@ -31,12 +33,20 @@ const useStyles = () => {
width: Math.max(windowSize.width / 2, 400),
maxWidth: '100%',
},
dialogContainer: {
backgroundColor: theme.backgroundColor,
borderRadius: theme.borderRadius,
paddingTop: theme.borderRadius,
marginLeft: 4,
marginRight: 4,
},
});
}, [windowSize.width]);
}, [windowSize.width, themeId]);
};
const DialogManager: React.FC<Props> = props => {
const [dialogModels, setPromptDialogs] = useState<PromptDialogData[]>([]);
const [dialogModels, setPromptDialogs] = useState<DialogData[]>([]);
const dialogControl = useDialogControl(setPromptDialogs);
const dialogControlRef = useRef(dialogControl);
@@ -51,17 +61,33 @@ const DialogManager: React.FC<Props> = props => {
}, []);
const theme = themeStyle(props.themeId);
const styles = useStyles();
const styles = useStyles(props.themeId);
const dialogComponents: React.ReactNode[] = [];
for (const dialog of dialogModels) {
dialogComponents.push(
<PromptDialog
key={dialog.key}
dialog={dialog}
themeId={props.themeId}
/>,
);
const dialogProps = {
key: dialog.key,
containerStyle: styles.dialogContainer,
themeId: props.themeId,
};
if (dialog.type === DialogType.Menu || dialog.type === DialogType.ButtonPrompt) {
dialogComponents.push(
<PromptDialog
dialog={dialog}
{...dialogProps}
/>,
);
} else if (dialog.type === DialogType.TextInput) {
dialogComponents.push(
<TextInputDialog
dialog={dialog}
{...dialogProps}
/>,
);
} else {
const exhaustivenessCheck: never = dialog.type;
throw new Error(`Unexpected dialog type ${exhaustivenessCheck}`);
}
}
// Web: Use a <Modal> wrapper for better keyboard focus handling.

View File

@@ -24,16 +24,18 @@ export interface DialogControl {
info(message: string): Promise<void>;
error(message: string): Promise<void>;
prompt(title: string, message: string, buttons?: PromptButtonSpec[], options?: PromptOptions): void;
promptForText(message: string): Promise<string>;
showMenu<IdType>(title: string, choices: MenuChoice<IdType>[]): Promise<IdType>;
}
export enum DialogType {
Prompt,
ButtonPrompt,
Menu,
TextInput,
}
export interface PromptDialogData {
type: DialogType;
export interface ButtonDialogData {
type: DialogType.ButtonPrompt|DialogType.Menu;
key: string;
title: string;
message: string;
@@ -41,3 +43,13 @@ export interface PromptDialogData {
onDismiss: (()=> void)|null;
}
export interface TextInputDialogData {
type: DialogType.TextInput;
key: string;
message: string;
onSubmit: (text: string)=> void;
onDismiss: ()=> void;
}
export type DialogData = ButtonDialogData | TextInputDialogData;

View File

@@ -1,10 +1,13 @@
import * as React from 'react';
import { FunctionComponent, ReactElement } from 'react';
import { FunctionComponent, ReactElement, useCallback, useContext } from 'react';
import { _ } from '@joplin/lib/locale';
import Folder, { FolderEntityWithChildren } from '@joplin/lib/models/Folder';
import { themeStyle } from './global-style';
import Dropdown, { DropdownListItem, OnValueChangedListener } from './Dropdown';
import { FolderEntity } from '@joplin/lib/services/database/types';
import { View } from 'react-native';
import { Button } from 'react-native-paper';
import { DialogContext } from './DialogManager';
interface FolderPickerProps {
disabled?: boolean;
@@ -16,6 +19,7 @@ interface FolderPickerProps {
darkText?: boolean;
themeId?: number;
coverableChildrenRight?: ReactElement|ReactElement[];
onNewFolder?: (title: string)=> void;
}
@@ -28,6 +32,7 @@ const FolderPicker: FunctionComponent<FolderPickerProps> = ({
placeholder,
darkText,
coverableChildrenRight,
onNewFolder,
themeId,
}) => {
const theme = themeStyle(themeId);
@@ -61,7 +66,15 @@ const FolderPicker: FunctionComponent<FolderPickerProps> = ({
return output;
};
return (
const dialogs = useContext(DialogContext);
const onNewFolderPress = useCallback(async () => {
const title = await dialogs.promptForText(_('New notebook title'));
if (title !== null) {
onNewFolder(title);
}
}, [dialogs, onNewFolder]);
const dropdown = (
<Dropdown
items={titlePickerItems(!!mustSelect)}
accessibilityHint={_('Selects a notebook')}
@@ -88,6 +101,19 @@ const FolderPicker: FunctionComponent<FolderPickerProps> = ({
}}
/>
);
if (onNewFolder) {
return <View style={{ flexDirection: 'column', flex: 1 }}>
{dropdown}
<Button
style={{ alignSelf: 'flex-end' }}
icon='plus'
onPress={onNewFolderPress}
>{_('Create new notebook')}</Button>
</View>;
} else {
return dropdown;
}
};
export default FolderPicker;

View File

@@ -6,6 +6,7 @@ import { _ } from '@joplin/lib/locale';
import { useCallback, useState } from 'react';
import DismissibleDialog, { DialogSize } from '../DismissibleDialog';
import { LinkButton } from '../buttons';
import NavService from '@joplin/lib/services/NavService';
interface Props {
wrapperStyle: ViewStyle;
@@ -51,6 +52,7 @@ const WebBetaButton: React.FC<Props> = props => {
<Text>{'At present, the web client is in beta. In the future, it may change significantly, or be removed.'}</Text>
<View style={feedbackContainerStyles}>
<LinkButton onPress={onLeaveFeedback}>{'Give feedback'}</LinkButton>
<LinkButton onPress={() => NavService.go('DocumentScanner')}>{'Test work-in-progress feature: Document scanner'}</LinkButton>
</View>
</DismissibleDialog>
</>

View File

@@ -64,6 +64,7 @@ interface ScreenHeaderProps {
onSaveButtonPress: OnPressCallback;
sortButton_press?: OnPressCallback;
onSearchButtonPress?: OnPressCallback;
onDeleteButtonPress?: OnPressCallback;
showSideMenuButton?: boolean;
showSearchButton?: boolean;
@@ -388,6 +389,22 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const customDeleteButton = (styles: any, onPress: OnPressCallback) => {
return (
<IconButton
onPress={onPress}
description={_('Delete')}
themeId={themeId}
contentWrapperStyle={styles.iconButton}
iconName='fas trash'
iconStyle={styles.topIcon}
/>
);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const pluginPanelToggleButton = (styles: any, onPress: OnPressCallback) => {
const allPluginViews = Object.values(this.props.plugins).map(plugin => Object.values(plugin.views)).flat();
@@ -610,6 +627,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
const showSearchButton = !!this.props.showSearchButton && !this.props.noteSelectionEnabled;
const showContextMenuButton = this.props.showContextMenuButton !== false;
const showBackButton = !!this.props.noteSelectionEnabled || this.props.showBackButton !== false;
const showStandardDeleteButton = !this.props.onDeleteButtonPress && !selectedFolderInTrash && this.props.noteSelectionEnabled;
let backButtonDisabled = !this.props.historyCanGoBack;
if (this.props.noteSelectionEnabled) backButtonDisabled = false;
@@ -621,7 +639,8 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
const betaIconComp = betaIconButton();
const selectAllButtonComp = !showSelectAllButton ? null : selectAllButton(this.styles(), () => this.selectAllButton_press());
const searchButtonComp = !showSearchButton ? null : searchButton(this.styles(), () => this.searchButton_press());
const deleteButtonComp = !selectedFolderInTrash && this.props.noteSelectionEnabled ? deleteButton(this.styles(), () => this.deleteButton_press(), headerItemDisabled) : null;
const customDeleteButtonComp = this.props.onDeleteButtonPress ? customDeleteButton(this.styles(), this.props.onDeleteButtonPress) : null;
const deleteButtonComp = showStandardDeleteButton ? deleteButton(this.styles(), () => this.deleteButton_press(), headerItemDisabled) : null;
const restoreButtonComp = selectedFolderInTrash && this.props.noteSelectionEnabled ? restoreButton(this.styles(), () => this.restoreButton_press(), headerItemDisabled) : null;
const duplicateButtonComp = !selectedFolderInTrash && this.props.noteSelectionEnabled ? duplicateButton(this.styles(), () => this.duplicateButton_press(), headerItemDisabled) : null;
const sortButtonComp = !this.props.noteSelectionEnabled && this.props.sortButton_press ? sortButton(this.styles(), () => this.props.sortButton_press()) : null;
@@ -673,6 +692,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
{selectAllButtonComp}
{searchButtonComp}
{deleteButtonComp}
{customDeleteButtonComp}
{restoreButtonComp}
{duplicateButtonComp}
{sortButtonComp}

View File

@@ -1,6 +1,6 @@
import * as React from 'react';
import { StyleSheet, View, Text, ScrollView, ViewStyle, Platform, AccessibilityInfo } from 'react-native';
import { StyleSheet, View, Text, ScrollView, ViewStyle, Platform, AccessibilityInfo, ScrollViewProps, TextStyle } from 'react-native';
import { _ } from '@joplin/lib/locale';
import { themeStyle } from './global-style';
import ComboBox, { Option } from './ComboBox';
@@ -23,9 +23,11 @@ interface Props {
mode: TagEditorMode;
style: ViewStyle;
onTagsChange: (newTags: string[])=> void;
headerStyle?: TextStyle;
searchResultProps?: ScrollViewProps;
}
const useStyles = (themeId: number) => {
const useStyles = (themeId: number, headerStyle: TextStyle|undefined) => {
return useMemo(() => {
const theme = themeStyle(themeId);
return StyleSheet.create({
@@ -73,6 +75,7 @@ const useStyles = (themeId: number) => {
...theme.headerStyle,
fontSize: theme.fontSize,
marginBottom: theme.itemMarginBottom,
...headerStyle,
},
divider: {
marginTop: theme.margin * 1.4,
@@ -87,7 +90,7 @@ const useStyles = (themeId: number) => {
color: theme.colorFaded,
},
});
}, [themeId]);
}, [themeId, headerStyle]);
};
type Styles = ReturnType<typeof useStyles>;
@@ -188,7 +191,7 @@ const TagsBox: React.FC<TagsBoxProps> = props => {
const normalizeTag = (tagText: string) => tagText.trim().toLowerCase();
const TagEditor: React.FC<Props> = props => {
const styles = useStyles(props.themeId);
const styles = useStyles(props.themeId, props.headerStyle);
const comboBoxItems = useMemo(() => {
return props.allTags
@@ -270,6 +273,7 @@ const TagEditor: React.FC<Props> = props => {
searchInputProps={{
autoCapitalize: 'none',
}}
searchResultProps={props.searchResultProps}
/>
</View>;
};

View File

@@ -42,6 +42,9 @@ const useStyles = (themeId: number) => {
borderRadius: 8,
padding: 8,
},
buttonText: {
textAlign: 'center',
},
});
}, [themeId]);
};
@@ -60,7 +63,8 @@ const LabelledIconButton: React.FC<Props> = ({ title, icon, style, themeId, ...o
<Text
variant='labelMedium'
ellipsizeMode='tail'
numberOfLines={1}
numberOfLines={2}
style={styles.buttonText}
>{title}</Text>
</View>
</TouchableRipple>;

View File

@@ -0,0 +1,164 @@
import * as React from 'react';
import { StyleSheet, View } from 'react-native';
import { connect } from 'react-redux';
import { AppState } from '../../../utils/types';
import { useCallback, useMemo, useState } from 'react';
import { themeStyle } from '../../global-style';
import CameraViewMultiPage from '../../CameraView/CameraViewMultiPage';
import { Dispatch } from 'redux';
import ScreenHeader from '../../ScreenHeader';
import { _ } from '@joplin/lib/locale';
import { CameraResult } from '../../CameraView/types';
import NotePreview, { CreateNoteEvent } from './NotePreview';
import shim from '@joplin/lib/shim';
import Note from '@joplin/lib/models/Note';
import Tag from '@joplin/lib/models/Tag';
import { Portal, ProgressBar, Snackbar } from 'react-native-paper';
import useBackHandler from '../../../utils/hooks/useBackHandler';
import Logger from '@joplin/utils/Logger';
import NavService from '@joplin/lib/services/NavService';
const logger = Logger.create('DocumentScanner');
interface Props {
dispatch: Dispatch;
themeId: number;
}
const useStyles = (themeId: number) => {
return useMemo(() => {
const theme = themeStyle(themeId);
return StyleSheet.create({
root: theme.rootStyle,
noRemainingPhotosContainer: {
margin: theme.margin,
gap: theme.margin,
},
progressBarContainer: {
flexGrow: 0,
},
});
}, [themeId]);
};
const DocumentScanner: React.FC<Props> = ({ themeId, dispatch }) => {
const styles = useStyles(themeId);
const [cameraVisible, setCameraVisible] = useState(true);
const [photos, setPhotos] = useState<CameraResult[]>([]);
const [snackbarMessage, setSnackbarMessage] = useState('');
const [creatingNote, setCreatingNote] = useState(false);
useBackHandler(() => {
if (photos.length && !cameraVisible) {
setCameraVisible(true);
return true;
}
return false;
});
const onDeleteLastPhoto = useCallback(() => {
if (photos.length <= 1) {
setCameraVisible(true);
}
setSnackbarMessage('');
setPhotos(photos => {
const result = [...photos];
result.pop();
return result;
});
}, [photos]);
const onCloseScreen = useCallback(() => {
setPhotos([]);
dispatch({ type: 'NAV_BACK' });
}, [dispatch]);
const onCreateNote = useCallback(async (event: CreateNoteEvent) => {
setSnackbarMessage(_('Creating note "%s"...', event.title));
setCreatingNote(true);
try {
const resources = [];
for (const image of photos) {
resources.push(await shim.createResourceFromPath(
image.uri,
{ title: event.title, mime: image.type },
));
}
const note = await Note.save({
title: event.title,
body: resources.map(
(image, index) => `![${_('Photo %d', index + 1)}](:/${image.id})`,
).join('\n\n'),
parent_id: event.parentId,
});
await Tag.setNoteTagsByTitles(note.id, event.tags);
await NavService.go('Note', { noteId: note.id });
} catch (error) {
logger.error('Error creating note', error);
await shim.showErrorDialog(`Failed to create note: ${error}`);
} finally {
setCreatingNote(false);
}
}, [photos]);
const onDismissSnackbar = useCallback(() => {
setSnackbarMessage('');
}, []);
const onHideCamera = useCallback(() => {
setCameraVisible(false);
}, []);
const renderContent = () => {
if (cameraVisible) {
return <CameraViewMultiPage
themeId={themeId}
onCancel={onCloseScreen}
onComplete={onHideCamera}
photos={photos}
onSetPhotos={setPhotos}
onInsertBarcode={()=>{}}
/>;
} else if (photos.length > 0) {
return <>
<ScreenHeader title={_('Note preview')} onDeleteButtonPress={onDeleteLastPhoto}/>
{creatingNote && <View style={styles.progressBarContainer}>
<ProgressBar visible indeterminate aria-label={_('Creating note.')}/>
</View>}
<NotePreview
imageCount={photos.length}
lastImage={photos[photos.length - 1]}
onCreateNote={creatingNote ? null : onCreateNote}
/>
</>;
} else {
// Error/loading state
return <>
<ScreenHeader title={'Document scanner'}/>
</>;
}
};
return <View style={styles.root}>
{renderContent()}
<Portal>
<Snackbar
key={`snackbar--${snackbarMessage}`}
visible={!!snackbarMessage}
onDismiss={onDismissSnackbar}
action={{
label: _('Dismiss'),
onPress: onDismissSnackbar,
}}
>{snackbarMessage}</Snackbar>
</Portal>
</View>;
};
export default connect((state: AppState) => ({
themeId: state.settings.theme,
}))(DocumentScanner);

View File

@@ -0,0 +1,178 @@
import * as React from 'react';
import { useMemo, useState, useCallback, useEffect } from 'react';
import { themeStyle } from '../../global-style';
import { ScrollView, StyleSheet, View } from 'react-native';
import { CameraResult } from '../../CameraView/types';
import TextInput from '../../TextInput';
import PhotoPreview from '../../CameraView/PhotoPreview';
import TagEditor, { TagEditorMode } from '../../TagEditor';
import { AppState } from '../../../utils/types';
import { connect } from 'react-redux';
import { FolderEntity, TagEntity } from '@joplin/lib/services/database/types';
import { _ } from '@joplin/lib/locale';
import FolderPicker from '../../FolderPicker';
import Folder from '@joplin/lib/models/Folder';
import Setting from '@joplin/lib/models/Setting';
import { formatMsToLocal } from '@joplin/utils/time';
import { PrimaryButton } from '../../buttons';
export interface CreateNoteEvent {
title: string;
tags: string[];
parentId: string;
}
type OnCreateNote = (event: CreateNoteEvent)=> void;
interface Props {
themeId: number;
imageCount: number;
lastImage: CameraResult;
allTags: TagEntity[];
allFolders: FolderEntity[];
selectedFolderId: string;
onCreateNote: null|OnCreateNote;
}
const useStyles = (themeId: number) => {
return useMemo(() => {
const theme = themeStyle(themeId);
return StyleSheet.create({
titleInput: {
color: theme.color,
fontSize: theme.fontSize,
},
rootScrollView: {
flex: 1,
width: '100%',
maxWidth: 700,
alignSelf: 'center',
},
photoBackground: {
},
photoLabel: {
},
tagEditor: {
marginHorizontal: theme.margin,
},
tagEditorHeader: {
fontWeight: 'normal',
},
folderPickerLine: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: theme.margin * 2,
margin: theme.margin,
marginBottom: theme.margin * 2,
},
folderPicker: {
flexGrow: 1,
},
actionButton: {
alignSelf: 'flex-end',
margin: theme.margin,
},
});
}, [themeId]);
};
const tagSearchResultsProps = {
// Required on Android when including one <ScrollView> inside another:
nestedScrollEnabled: true,
};
const NotePreview: React.FC<Props> = ({
themeId, lastImage, imageCount, allTags, onCreateNote, allFolders, selectedFolderId: propsSelectedFolderId,
}) => {
const styles = useStyles(themeId);
const [title, setTitle] = useState('');
const [tags, setTags] = useState([]);
const [selectedFolderId, setSelectedFolderId] = useState(propsSelectedFolderId);
const realFolders = useMemo(() => {
return Folder.getRealFolders(allFolders);
}, [allFolders]);
useEffect(() => {
// Don't allow selecting a virtual folder
if (selectedFolderId && realFolders.every(folder => folder.id !== selectedFolderId)) {
setSelectedFolderId('');
}
}, [realFolders, selectedFolderId]);
useEffect(() => {
const template = Setting.value('scanner.titleTemplate');
const date = formatMsToLocal(Date.now(), Setting.value('dateFormat'));
setTitle(
template.replace(/{date}/g, date)
.replace(/{count}/g, `${imageCount}`),
);
}, [imageCount]);
const onNewNote = useCallback(() => {
if (!onCreateNote) return;
onCreateNote({
tags,
title,
parentId: selectedFolderId ?? '',
});
}, [onCreateNote, tags, title, selectedFolderId]);
const onNewFolder = useCallback(async (title: string) => {
const folder = await Folder.save({ title });
setSelectedFolderId(folder.id);
}, []);
return <ScrollView style={styles.rootScrollView}>
<TextInput
style={styles.titleInput}
themeId={themeId}
value={title}
onChangeText={setTitle}
/>
<View style={styles.folderPickerLine}>
<PhotoPreview
source={lastImage}
backgroundStyle={styles.photoBackground}
textStyle={styles.photoLabel}
label={imageCount}
/>
<FolderPicker
themeId={themeId}
darkText
placeholder={_('Select notebook')}
folders={realFolders}
onValueChange={setSelectedFolderId}
selectedFolderId={selectedFolderId}
mustSelect={true}
onNewFolder={onNewFolder}
/>
</View>
<TagEditor
themeId={themeId}
tags={tags}
mode={TagEditorMode.Compact}
allTags={allTags}
style={styles.tagEditor}
onTagsChange={setTags}
headerStyle={styles.tagEditorHeader}
searchResultProps={tagSearchResultsProps}
/>
<PrimaryButton
onPress={onNewNote}
style={styles.actionButton}
disabled={!onCreateNote}
>{_('Create note')}</PrimaryButton>
</ScrollView>;
};
export default connect((state: AppState) => ({
allTags: state.tags,
allFolders: state.folders,
selectedFolderId: state.selectedFolderId,
themeId: state.settings.theme,
}))(NotePreview);

View File

@@ -10,6 +10,7 @@ import TextButton, { ButtonSize, ButtonType } from '../../buttons/TextButton';
import { useCallback, useMemo, useRef } from 'react';
import Logger from '@joplin/utils/Logger';
import focusView from '../../../utils/focusView';
import NavService from '@joplin/lib/services/NavService';
const logger = Logger.create('NewNoteButton');
@@ -26,7 +27,8 @@ const makeNewNote = (isTodo: boolean, action?: AttachFileAction) => {
const styles = StyleSheet.create({
buttonRow: {
flexDirection: 'row',
flexWrap: 'wrap',
flexWrap: 'wrap-reverse',
justifyContent: 'space-between',
gap: 2,
},
mainButtonRow: {
@@ -34,9 +36,9 @@ const styles = StyleSheet.create({
gap: 12,
},
shortcutButton: {
flexGrow: 1,
flexGrow: 0,
flexShrink: 1,
flexBasis: 0,
width: 88,
},
mainButton: {
flexShrink: 1,
@@ -57,9 +59,12 @@ const styles = StyleSheet.create({
const NewNoteButton: React.FC<Props> = _props => {
const newNoteRef = useRef<View|null>(null);
const renderShortcutButton = (action: AttachFileAction, icon: string, title: string) => {
type ActionType = AttachFileAction|(()=> void);
const renderShortcutButton = (action: ActionType, icon: string, title: string) => {
const actionSource = typeof action === 'function' ? null : action;
action = typeof action === 'function' ? action : () => makeNewNote(false, actionSource);
return <LabelledIconButton
onPress={() => makeNewNote(false, action)}
onPress={action}
style={styles.shortcutButton}
title={title}
accessibilityHint={_('Creates a new note with an attachment of type %s', title)}
@@ -73,6 +78,7 @@ const NewNoteButton: React.FC<Props> = _props => {
{renderShortcutButton(AttachFileAction.RecordAudio, 'material microphone', _('Recording'))}
{renderShortcutButton(AttachFileAction.TakePhoto, 'material camera', _('Camera'))}
{renderShortcutButton(AttachFileAction.AttachDrawing, 'material draw', _('Drawing'))}
{renderShortcutButton(() => NavService.go('DocumentScanner'), 'material data-matrix-scan', _('Scan notebook'))}
</View>
<Divider/>
<View style={[styles.buttonRow, styles.mainButtonRow]}>

View File

@@ -146,6 +146,7 @@ import FocusControl from './components/accessibility/FocusControl/FocusControl';
import SsoLoginScreen from './components/screens/SsoLoginScreen';
import SamlShared from '@joplin/lib/components/shared/SamlShared';
import NoteRevisionViewer from './components/screens/NoteRevisionViewer';
import DocumentScanner from './components/screens/DocumentScanner/DocumentScanner';
const logger = Logger.create('root');
@@ -279,6 +280,10 @@ const navHistory: any[] = [];
function historyCanGoBackTo(route: any) {
if (route.routeName === 'Folder') return false;
// This is an intermediate screen that acts more like a modal -- it should be skipped in the
// navigation history.
if (route.routeName === 'DocumentScanner') return false;
// There's no point going back to these screens in general and, at least in OneDrive case,
// it can be buggy to do so, due to incorrectly relying on global state (reg.syncTarget...)
if (route.routeName === 'OneDriveLogin') return false;
@@ -1320,6 +1325,7 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
Status: { screen: StatusScreen },
Search: { screen: SearchScreen },
Config: { screen: ConfigScreen },
DocumentScanner: { screen: DocumentScanner },
};

View File

@@ -1,20 +1,24 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import BackButtonService from '../../services/BackButtonService';
type OnBackPress = ()=>(void|boolean);
const useBackHandler = (onBackPress: OnBackPress|null) => {
const onBackPressRef = useRef(onBackPress);
onBackPressRef.current = onBackPress ?? (() => {});
const hasHandler = !!onBackPress;
useEffect(() => {
if (!onBackPress) return () => {};
if (!hasHandler) return () => {};
const handler = () => {
return !!(onBackPress() ?? true);
return !!(onBackPressRef.current() ?? true);
};
BackButtonService.addHandler(handler);
return () => {
BackButtonService.removeHandler(handler);
};
}, [onBackPress]);
}, [hasHandler]);
};
export default useBackHandler;

View File

@@ -11,6 +11,7 @@ const makeMockDialogControl = (onPrompt: OnPrompt): DialogControl => {
prompt: jest.fn((_title, _message, buttons, options) => {
onPrompt(buttons, options.onDismiss);
}),
promptForText: jest.fn(),
showMenu: jest.fn(),
};
};

View File

@@ -1841,6 +1841,16 @@ const builtInMetadata = (Setting: typeof SettingType) => {
section: 'note',
},
'scanner.titleTemplate': {
value: 'Scan: {date} ({count})',
type: SettingItemType.String,
public: true,
appTypes: [AppType.Mobile],
label: () => _('Document scanner: Title template'),
description: () => _('Default title to use for documents created by the scanner.'),
section: 'note',
},
'trash.autoDeletionEnabled': {
value: true,
type: SettingItemType.Bool,