From b7f90000648cbe41e5d617ad9863135c96b81ac2 Mon Sep 17 00:00:00 2001
From: Elysia <71698422+aiko-chan-ai@users.noreply.github.com>
Date: Fri, 17 Jul 2026 16:53:56 +0700
Subject: [PATCH 1/7] first
---
.../app/src/main/assets/css/toolWrapper.css | 6 +-
src/components/Common.tsx | 19 +-
src/components/Common/ToggleButton.tsx | 7 +-
.../IconButtonV2/AnimatedIconButton.tsx | 90 +++++
.../Modal/KeyboardAvoidingModal.tsx | 168 ++++++++++
src/components/Switch/SwitchItem.tsx | 10 +-
src/components/TextInput/index.tsx | 71 ++++
src/components/index.ts | 2 +
src/hooks/common/useKeyboardHeight.ts | 31 ++
.../novel/components/EditInfoModal.tsx | 314 ++++++++----------
10 files changed, 538 insertions(+), 180 deletions(-)
create mode 100644 src/components/IconButtonV2/AnimatedIconButton.tsx
create mode 100644 src/components/Modal/KeyboardAvoidingModal.tsx
create mode 100644 src/components/TextInput/index.tsx
create mode 100644 src/hooks/common/useKeyboardHeight.ts
diff --git a/android/app/src/main/assets/css/toolWrapper.css b/android/app/src/main/assets/css/toolWrapper.css
index 580a8ece70..7097ff5d82 100644
--- a/android/app/src/main/assets/css/toolWrapper.css
+++ b/android/app/src/main/assets/css/toolWrapper.css
@@ -12,7 +12,7 @@
right: unset;
left: 50%;
transform: translateX(-50%);
- bottom: 120px;
+ bottom: calc(68px + var(--bottom-inset));
display: flex;
flex-direction: row-reverse;
align-items: center;
@@ -20,7 +20,7 @@
@media only screen and (min-width: 500px) {
#ToolWrapper.horizontal {
- bottom: 80px;
+ bottom: calc(28px + var(--bottom-inset));
}
}
@@ -32,7 +32,7 @@
#ToolWrapper.hidden.horizontal {
opacity: 0;
- bottom: 80px;
+ bottom: calc(28px + var(--bottom-inset));
right: unset;
}
diff --git a/src/components/Common.tsx b/src/components/Common.tsx
index 2c9e7b9f83..f5bb2cb008 100644
--- a/src/components/Common.tsx
+++ b/src/components/Common.tsx
@@ -4,10 +4,27 @@ import { StyleSheet, View } from 'react-native';
const Row = ({
children,
style = {},
+ horizontalSpacing,
+ verticalSpacing,
}: {
children?: React.ReactNode;
style?: any;
-}) => {children};
+ horizontalSpacing?: number | `${number}%`;
+ verticalSpacing?: number | `${number}%`;
+}) => (
+
+ {children}
+
+);
export { Row };
diff --git a/src/components/Common/ToggleButton.tsx b/src/components/Common/ToggleButton.tsx
index d00aff98e7..781a9ccd6a 100644
--- a/src/components/Common/ToggleButton.tsx
+++ b/src/components/Common/ToggleButton.tsx
@@ -11,7 +11,9 @@ import { ThemeColors } from '../../theme/types';
const getToggleButtonPressableStyle = (
selected: boolean,
theme: ThemeColors,
+ disabled?: boolean,
) => ({
+ opacity: disabled ? 0.6 : 1,
backgroundColor: selected
? Color(theme.primary).alpha(0.12).string()
: 'transparent',
@@ -29,6 +31,7 @@ interface ToggleButtonProps {
theme: ThemeColors;
color?: string;
onPress: () => void;
+ disabled?: boolean;
}
export const ToggleButton: React.FC = ({
@@ -37,15 +40,17 @@ export const ToggleButton: React.FC = ({
theme,
color,
onPress,
+ disabled,
}) => (
void;
+ theme: ThemeColors;
+ style?: ViewStyle;
+ rotation?: SharedValue;
+ scale?: SharedValue;
+};
+
+const AnimatedIconButton: React.FC = ({
+ name,
+ color,
+ size = 24,
+ padding = 8,
+ onPress,
+ disabled,
+ theme,
+ style,
+ rotation,
+ scale: _scale,
+}) => {
+ const IconStyle = useAnimatedStyle(() => {
+ const rotate = rotation
+ ? withTiming(rotation.value + 'deg', { duration: 250 })
+ : '0deg';
+ const scale = _scale ? withTiming(_scale.value, { duration: 250 }) : 1;
+ return {
+ textAlign: 'center',
+ transform: [
+ {
+ rotate,
+ },
+ {
+ scale,
+ },
+ ],
+ };
+ });
+ return (
+
+
+
+
+
+ );
+};
+export default React.memo(AnimatedIconButton);
+
+const styles = StyleSheet.create({
+ container: {
+ borderRadius: 50,
+ overflow: 'hidden',
+ },
+ pressable: {
+ padding: 8,
+ },
+});
diff --git a/src/components/Modal/KeyboardAvoidingModal.tsx b/src/components/Modal/KeyboardAvoidingModal.tsx
new file mode 100644
index 0000000000..e47e0a2e6b
--- /dev/null
+++ b/src/components/Modal/KeyboardAvoidingModal.tsx
@@ -0,0 +1,168 @@
+import Button from '@components/Button/Button';
+import { useTheme } from '@hooks/persisted';
+import { getString } from '@strings/translations';
+import { ThemeColors } from '@theme/types';
+import React from 'react';
+import {
+ Keyboard,
+ ScrollView,
+ StyleSheet,
+ Text,
+ useWindowDimensions,
+ View,
+} from 'react-native';
+import { useAnimatedKeyboard } from 'react-native-keyboard-controller';
+import { Modal, ModalProps, overlay, Portal } from 'react-native-paper';
+import Animated, {
+ FadeIn,
+ FadeOut,
+ useAnimatedStyle,
+ withClamp,
+ withTiming,
+} from 'react-native-reanimated';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+const MODAL_MARGIN = 24;
+const BORDER_RADIUS = 28;
+
+const getModalTitleColor = (theme: ThemeColors) => ({
+ color: theme.onSurface,
+});
+
+export type DefaultModalProps = {
+ title: string;
+ onSave: () => boolean;
+ onDismiss: () => void;
+ onCancel?: () => void;
+ onReset?: () => void;
+} & Omit;
+
+const KeyboardAvoidingModal: React.FC = ({
+ visible,
+ onDismiss: _onDismiss,
+ onSave,
+ onCancel,
+ onReset,
+ title,
+ children,
+ ...props
+}) => {
+ const theme = useTheme();
+ const insets = useSafeAreaInsets();
+ const { height: windowHeight } = useWindowDimensions();
+ const keyboard = useAnimatedKeyboard();
+
+ const onDismiss = () => {
+ Keyboard.dismiss();
+ _onDismiss?.();
+ };
+
+ const dismiss = (cb?: () => void | boolean) => {
+ if (cb?.() === false) return;
+ onDismiss();
+ };
+
+ const default_availableHeight = windowHeight - insets.top;
+ const animatedContainerStyle = useAnimatedStyle(() => {
+ const kb = keyboard.height.value;
+
+ const availableHeight =
+ default_availableHeight - Math.max(insets.bottom, kb);
+ return {
+ maxHeight: withClamp(
+ { min: 200 },
+ withTiming(availableHeight, { duration: 0 }),
+ ),
+ marginBottom: kb,
+ };
+ }, [insets.bottom, default_availableHeight]);
+
+ return (
+
+
+
+
+ {title}
+
+
+
+
+ {children}
+
+
+
+
+ {onReset ? (
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default KeyboardAvoidingModal;
+
+const styles = StyleSheet.create({
+ modalWrapper: {
+ justifyContent: 'center',
+ paddingHorizontal: MODAL_MARGIN,
+ },
+ modalContainer: {
+ borderRadius: BORDER_RADIUS,
+ shadowColor: 'transparent',
+ },
+ modalTitle: {
+ fontSize: 24,
+ lineHeight: 24,
+ padding: 24,
+ },
+ body: {
+ flexShrink: 1,
+ minHeight: 0,
+ },
+ content: {
+ paddingBottom: 16,
+ paddingHorizontal: 24,
+ },
+ buttonRow: {
+ alignItems: 'center',
+ flexDirection: 'row',
+ marginBottom: -8,
+ marginHorizontal: -8,
+ padding: 24,
+ paddingTop: 8,
+ },
+ flex: {
+ flex: 1,
+ },
+});
diff --git a/src/components/Switch/SwitchItem.tsx b/src/components/Switch/SwitchItem.tsx
index dc4ec93777..9f7b5884e3 100644
--- a/src/components/Switch/SwitchItem.tsx
+++ b/src/components/Switch/SwitchItem.tsx
@@ -15,7 +15,9 @@ interface SwitchItemProps {
value: boolean;
label: string;
description?: string;
+ descriptionNumberOfLines?: number;
onPress: () => void;
+ onLongPress?: () => void;
theme: ThemeColors;
style?: StyleProp;
}
@@ -23,7 +25,9 @@ interface SwitchItemProps {
const SwitchItem: React.FC = ({
label,
description,
+ descriptionNumberOfLines,
onPress,
+ onLongPress,
theme,
value,
style,
@@ -32,11 +36,15 @@ const SwitchItem: React.FC = ({
android_ripple={{ color: theme.rippleColor }}
style={[styles.container, style]}
onPress={onPress}
+ onLongPress={onLongPress}
>
{label}
{description ? (
-
+
{description}
) : null}
diff --git a/src/components/TextInput/index.tsx b/src/components/TextInput/index.tsx
new file mode 100644
index 0000000000..1cfe158ad3
--- /dev/null
+++ b/src/components/TextInput/index.tsx
@@ -0,0 +1,71 @@
+import { useTheme } from '@hooks/persisted';
+import React, { useState } from 'react';
+import { StyleSheet, TextInputProps as RNTextInputProps } from 'react-native';
+import { TextInput as RNTextInput } from 'react-native-gesture-handler';
+
+interface TextInputProps extends RNTextInputProps {
+ error?: boolean;
+ value?: never;
+ forceFocused?: boolean;
+}
+
+const TextInput = ({
+ onBlur,
+ onFocus,
+ error,
+ forceFocused,
+ style,
+ ...props
+}: TextInputProps) => {
+ const theme = useTheme();
+
+ const [inputFocused, setInputFocused] = useState(false);
+
+ const _onFocus: RNTextInputProps['onFocus'] = e => {
+ setInputFocused(true);
+ onFocus?.(e);
+ };
+ const _onBlur: RNTextInputProps['onBlur'] = e => {
+ setInputFocused(false);
+ onBlur?.(e);
+ };
+
+ const isFocused = forceFocused ?? inputFocused;
+ const borderWidth = isFocused || error ? 2 : 1;
+ const margin = isFocused || error ? 0 : 1;
+ return (
+
+ );
+};
+
+export default TextInput;
+
+const styles = StyleSheet.create({
+ textInput: {
+ borderRadius: 4,
+ borderStyle: 'solid',
+ fontSize: 16,
+ paddingHorizontal: 16,
+ paddingVertical: 10,
+ },
+});
diff --git a/src/components/index.ts b/src/components/index.ts
index d5a2ad95a4..43624978ba 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -9,6 +9,7 @@ export { default as ConfirmationDialog } from './ConfirmationDialog/Confirmation
export { DialogTitle } from './DialogTitle';
export { default as EmptyView } from './EmptyView/EmptyView';
export { default as ErrorScreenV2 } from './ErrorScreenV2/ErrorScreenV2';
+export { default as AnimatedIconButton } from './IconButtonV2/AnimatedIconButton';
export { default as IconButtonV2 } from './IconButtonV2/IconButtonV2';
export { default as List } from './List/List';
export { default as LoadingMoreIndicator } from './LoadingMoreIndicator/LoadingMoreIndicator';
@@ -23,3 +24,4 @@ export { default as SearchbarV2 } from './SearchbarV2/SearchbarV2';
export { SegmentedControl } from './SegmentedControl';
export { default as SwitchItem } from './Switch/SwitchItem';
export { default as TaskLogDialog } from './TaskLogDialog';
+export { default as TextInput } from './TextInput';
diff --git a/src/hooks/common/useKeyboardHeight.ts b/src/hooks/common/useKeyboardHeight.ts
new file mode 100644
index 0000000000..3af2afc9c4
--- /dev/null
+++ b/src/hooks/common/useKeyboardHeight.ts
@@ -0,0 +1,31 @@
+import { useEffect, useState } from 'react';
+import { Keyboard, KeyboardEvent } from 'react-native';
+
+export const useKeyboardHeight = () => {
+ const [keyboardHeight, setKeyboardHeight] = useState(0);
+
+ useEffect(() => {
+ function onKeyboardDidShow(e: KeyboardEvent) {
+ setKeyboardHeight(e.endCoordinates.height);
+ }
+
+ function onKeyboardDidHide() {
+ setKeyboardHeight(0);
+ }
+
+ const showSubscription = Keyboard.addListener(
+ 'keyboardDidShow',
+ onKeyboardDidShow,
+ );
+ const hideSubscription = Keyboard.addListener(
+ 'keyboardDidHide',
+ onKeyboardDidHide,
+ );
+ return () => {
+ showSubscription.remove();
+ hideSubscription.remove();
+ };
+ }, []);
+
+ return keyboardHeight;
+};
diff --git a/src/screens/novel/components/EditInfoModal.tsx b/src/screens/novel/components/EditInfoModal.tsx
index 83e0c3f5b1..cd15454798 100644
--- a/src/screens/novel/components/EditInfoModal.tsx
+++ b/src/screens/novel/components/EditInfoModal.tsx
@@ -1,4 +1,4 @@
-import { Button, Modal } from '@components';
+import KeyboardAvoidingModal from '@components/Modal/KeyboardAvoidingModal';
import { updateNovelInfo } from '@database/queries/NovelQueries';
import { NovelInfo } from '@database/types';
import { NovelStatus } from '@plugins/types';
@@ -15,7 +15,6 @@ import {
Text,
View,
} from 'react-native';
-import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
import { Portal, TextInput } from 'react-native-paper';
interface EditInfoModalProps {
@@ -27,7 +26,6 @@ interface EditInfoModalProps {
}
// --- Dynamic style helpers ---
-const getModalTitleColor = (theme: ThemeColors) => ({ color: theme.onSurface });
const getStatusLabelColor = (theme: ThemeColors) => ({
color: theme.onSurfaceVariant,
});
@@ -40,8 +38,6 @@ const getStatusChipText = (selected: boolean, theme: ThemeColors) => ({
color: selected ? theme.primary : theme.onSurfaceVariant,
});
const getGenreListStyle = () => styles.genreList;
-const getButtonRowStyle = () => styles.buttonRow;
-const getFlex1 = () => styles.flex1;
// --- Main Component ---
const EditInfoModal = ({
@@ -53,7 +49,6 @@ const EditInfoModal = ({
}: EditInfoModalProps) => {
const initialNovelInfo = { ...novel };
const [novelInfo, setNovelInfo] = useState(novel);
- const [resetKey, setResetKey] = useState(0);
const [newGenre, setNewGenre] = useState('');
const [genreKey, setGenreKey] = useState(0);
@@ -72,171 +67,152 @@ const EditInfoModal = ({
return (
-
-
-
- {getString('novelScreen.edit.info')}
+ {
+ setNovel(novelInfo);
+ updateNovelInfo(novelInfo);
+ hideModal();
+ return true;
+ }}
+ onCancel={hideModal}
+ onReset={() => {
+ setNovelInfo(initialNovelInfo);
+ updateNovelInfo(initialNovelInfo);
+ }}
+ >
+
+
+ {getString('novelScreen.edit.status')}
-
-
- {getString('novelScreen.edit.status')}
-
-
- {status.map((item, index) => (
-
+ {status.map((item, index) => (
+
+
+ setNovelInfo(prev => ({ ...prev, status: item }))
+ }
>
-
- setNovelInfo(prev => ({ ...prev, status: item }))
- }
+
-
- {translateNovelStatus(item)}
-
-
-
- ))}
-
-
-
- setNovelInfo(prev => ({ ...prev, name: text }))
- }
- dense
- style={styles.inputWrapper}
- />
-
- setNovelInfo(prev => ({ ...prev, author: text }))
- }
- dense
- style={styles.inputWrapper}
- />
-
- setNovelInfo(prev => ({ ...prev, artist: text }))
- }
- dense
- style={styles.inputWrapper}
- />
-
- setNovelInfo(prev => ({ ...prev, summary: text }))
- }
- theme={{ colors: { ...theme } }}
- dense
- style={styles.inputWrapper}
- />
+ {translateNovelStatus(item)}
+
+
+
+ ))}
+
+
+ setNovelInfo(prev => ({ ...prev, name: text }))}
+ dense
+ style={styles.inputWrapper}
+ />
+
+ setNovelInfo(prev => ({ ...prev, author: text }))
+ }
+ dense
+ style={styles.inputWrapper}
+ />
+
+ setNovelInfo(prev => ({ ...prev, artist: text }))
+ }
+ dense
+ style={styles.inputWrapper}
+ />
+
+ setNovelInfo(prev => ({ ...prev, summary: text }))
+ }
+ theme={{ colors: { ...theme } }}
+ dense
+ style={styles.inputWrapper}
+ />
- setNewGenre(text)}
- onSubmitEditing={() => {
- const newGenreTrimmed = newGenre.trim();
+ setNewGenre(text)}
+ onSubmitEditing={() => {
+ const newGenreTrimmed = newGenre.trim();
- if (newGenreTrimmed === '') {
- return;
- }
+ if (newGenreTrimmed === '') {
+ return;
+ }
- setNovelInfo(prevVal => ({
- ...prevVal,
- genres: novelInfo.genres
- ? `${novelInfo.genres},` + newGenreTrimmed
- : newGenreTrimmed,
- }));
- setNewGenre('');
- setGenreKey(prev => prev + 1);
- }}
- theme={{ colors: { ...theme } }}
- dense
- style={styles.inputWrapper}
- />
+ setNovelInfo(prevVal => ({
+ ...prevVal,
+ genres: novelInfo.genres
+ ? `${novelInfo.genres},` + newGenreTrimmed
+ : newGenreTrimmed,
+ }));
+ setNewGenre('');
+ setGenreKey(prev => prev + 1);
+ }}
+ theme={{ colors: { ...theme } }}
+ dense
+ style={styles.inputWrapper}
+ />
- {novelInfo.genres !== undefined && novelInfo.genres !== '' ? (
- 'novelTag' + index}
- renderItem={({ item }) => (
- removeTag(item)}>
- {item}
-
- )}
- showsHorizontalScrollIndicator={false}
- />
- ) : null}
-
-
-
-
-
-
-
-
+ {novelInfo.genres !== undefined && novelInfo.genres !== '' ? (
+ 'novelTag' + index}
+ renderItem={({ item }) => (
+ removeTag(item)}>
+ {item}
+
+ )}
+ showsHorizontalScrollIndicator={false}
+ />
+ ) : null}
+
);
};
@@ -286,10 +262,6 @@ const styles = StyleSheet.create({
fontSize: 14,
marginBottom: 12,
},
- modalTitle: {
- fontSize: 24,
- marginBottom: 16,
- },
statusRow: {
marginVertical: 8,
flexDirection: 'row',
@@ -309,12 +281,6 @@ const styles = StyleSheet.create({
genreList: {
marginVertical: 8,
},
- buttonRow: {
- flexDirection: 'row',
- },
- flex1: {
- flex: 1,
- },
genreChipContainer: {
flex: 1,
flexDirection: 'row',
From 7658dd1f59406b1fa73d9de05b316b063228f43b Mon Sep 17 00:00:00 2001
From: Elysia <71698422+aiko-chan-ai@users.noreply.github.com>
Date: Fri, 17 Jul 2026 17:55:51 +0700
Subject: [PATCH 2/7] refactor(reader): modularize WebViewReader and TTS
lifecycle
---
src/screens/reader/ReaderScreen.tsx | 7 +-
.../Hooks/useReaderMessageHandler.ts | 163 ++++
.../components/Hooks/useReaderSettings.ts | 77 ++
.../reader/components/Hooks/useReadingTime.ts | 77 ++
src/screens/reader/components/Hooks/useTTS.ts | 381 +++++++++
.../reader/components/Hooks/webViewEvents.ts | 46 ++
.../reader/components/WebViewReader.tsx | 725 +++---------------
7 files changed, 838 insertions(+), 638 deletions(-)
create mode 100644 src/screens/reader/components/Hooks/useReaderMessageHandler.ts
create mode 100644 src/screens/reader/components/Hooks/useReaderSettings.ts
create mode 100644 src/screens/reader/components/Hooks/useReadingTime.ts
create mode 100644 src/screens/reader/components/Hooks/useTTS.ts
create mode 100644 src/screens/reader/components/Hooks/webViewEvents.ts
diff --git a/src/screens/reader/ReaderScreen.tsx b/src/screens/reader/ReaderScreen.tsx
index 8b8109fae9..81805a18ab 100644
--- a/src/screens/reader/ReaderScreen.tsx
+++ b/src/screens/reader/ReaderScreen.tsx
@@ -66,7 +66,7 @@ export const ChapterContent = ({
navigation,
openDrawer,
}: ChapterContentProps) => {
- const { left, right } = useSafeAreaInsets();
+ const { left, right, bottom } = useSafeAreaInsets();
const {
novel,
chapter,
@@ -85,6 +85,10 @@ export const ChapterContent = ({
const [bookmarked, setBookmarked] = useState(
chapter.bookmark ?? false,
);
+ const nonZeroBottom = useRef(bottom);
+ if (nonZeroBottom.current !== bottom && bottom !== 0) {
+ nonZeroBottom.current = bottom;
+ }
useEffect(() => {
setBookmarked(chapter.bookmark ?? false);
@@ -188,6 +192,7 @@ export const ChapterContent = ({
diff --git a/src/screens/reader/components/Hooks/useReaderMessageHandler.ts b/src/screens/reader/components/Hooks/useReaderMessageHandler.ts
new file mode 100644
index 0000000000..5b719ccd78
--- /dev/null
+++ b/src/screens/reader/components/Hooks/useReaderMessageHandler.ts
@@ -0,0 +1,163 @@
+import * as ScreenOrientation from 'expo-screen-orientation';
+import { useCallback, useRef } from 'react';
+
+import type { NativeFindResult } from '../../hooks/useNativeChapterSearch';
+import type useTTS from './useTTS';
+import { parseWebViewEvent } from './webViewEvents';
+
+type TTSController = ReturnType;
+
+type UseReaderMessageHandlerOptions = {
+ onPress: () => void;
+ onFindResult: (result: NativeFindResult) => void;
+ navigateChapter: (direction: 'NEXT' | 'PREV') => void;
+ saveProgress: (progress: number) => void;
+ resetAutoScroll: () => void;
+ refetch: () => void;
+ tts: TTSController;
+};
+
+const isRecord = (value: unknown): value is Record =>
+ typeof value === 'object' && value !== null;
+
+export default function useReaderMessageHandler({
+ onPress,
+ onFindResult,
+ navigateChapter,
+ saveProgress,
+ resetAutoScroll,
+ refetch,
+ tts,
+}: UseReaderMessageHandlerOptions) {
+ const nextChapterScreenVisible = useRef(false);
+ const pendingScrollPosition = useRef<'start' | 'end' | null>(null);
+ const getNextChapterScreenVisible = useCallback(
+ () => nextChapterScreenVisible.current,
+ [],
+ );
+ const getPendingScrollPosition = useCallback(
+ () => pendingScrollPosition.current,
+ [],
+ );
+ const clearPendingScrollPosition = useCallback(() => {
+ pendingScrollPosition.current = null;
+ }, []);
+
+ const handleMessage = useCallback(
+ (payload: string) => {
+ const event = parseWebViewEvent(payload);
+ if (!event) return;
+
+ switch (event.type) {
+ case 'user-interaction':
+ resetAutoScroll();
+ break;
+ case 'tts-queue':
+ tts.handleQueue(event);
+ break;
+ case 'hide':
+ onPress();
+ break;
+ case 'next':
+ nextChapterScreenVisible.current = true;
+ if (event.initialScrollPosition) {
+ pendingScrollPosition.current = event.initialScrollPosition;
+ }
+ if (event.autoStartTTS) {
+ tts.scheduleAutoStart();
+ }
+ navigateChapter('NEXT');
+ break;
+ case 'prev':
+ if (event.initialScrollPosition) {
+ pendingScrollPosition.current = event.initialScrollPosition;
+ }
+ if (event.autoStartTTS) {
+ tts.scheduleAutoStart();
+ }
+ navigateChapter('PREV');
+ break;
+ case 'save':
+ if (typeof event.data === 'number') {
+ saveProgress(event.data);
+ }
+ break;
+ case 'find-result':
+ if (
+ isRecord(event.data) &&
+ typeof event.data.query === 'string' &&
+ typeof event.data.activeMatchOrdinal === 'number' &&
+ typeof event.data.numberOfMatches === 'number' &&
+ typeof event.data.isDoneCounting === 'boolean'
+ ) {
+ onFindResult({
+ query: event.data.query,
+ current:
+ event.data.numberOfMatches > 0
+ ? event.data.activeMatchOrdinal + 1
+ : 0,
+ total: event.data.numberOfMatches,
+ isDoneCounting: event.data.isDoneCounting,
+ });
+ }
+ break;
+ case 'speak':
+ tts.handleSpeak(event);
+ break;
+ case 'pause-speak':
+ tts.handlePause();
+ break;
+ case 'stop-speak':
+ tts.handleStop();
+ break;
+ case 'tts-state':
+ tts.handleState(event);
+ break;
+ case 'refetch':
+ refetch();
+ break;
+ case 'video-fullscreen-enter':
+ ScreenOrientation.lockAsync(
+ ScreenOrientation.OrientationLock.LANDSCAPE,
+ ).catch(() => {});
+ break;
+ case 'video-fullscreen-exit':
+ ScreenOrientation.unlockAsync().catch(() => {});
+ break;
+ case 'console': {
+ const method = ['debug', 'error', 'info', 'log', 'warn'].includes(
+ event.method ?? '',
+ )
+ ? (event.method as 'debug' | 'error' | 'info' | 'log' | 'warn')
+ : 'log';
+ // eslint-disable-next-line no-console
+ console[method]('[WebView]', ...(event.args ?? []));
+ break;
+ }
+ case 'error':
+ // eslint-disable-next-line no-console
+ console.error('[WebView Error]', event.msg);
+ break;
+ default:
+ // eslint-disable-next-line no-console
+ console.warn(`Unknown event: ${event.type}`, event);
+ }
+ },
+ [
+ navigateChapter,
+ onFindResult,
+ onPress,
+ refetch,
+ resetAutoScroll,
+ saveProgress,
+ tts,
+ ],
+ );
+
+ return {
+ clearPendingScrollPosition,
+ getNextChapterScreenVisible,
+ getPendingScrollPosition,
+ handleMessage,
+ };
+}
diff --git a/src/screens/reader/components/Hooks/useReaderSettings.ts b/src/screens/reader/components/Hooks/useReaderSettings.ts
new file mode 100644
index 0000000000..a98d805cf8
--- /dev/null
+++ b/src/screens/reader/components/Hooks/useReaderSettings.ts
@@ -0,0 +1,77 @@
+import type {
+ ChapterGeneralSettings,
+ ChapterReaderSettings,
+} from '@hooks/persisted/useSettings';
+import { useEffect, useRef } from 'react';
+import { NativeEventEmitter, NativeModules } from 'react-native';
+import type WebView from 'react-native-webview';
+
+const { RNDeviceInfo } = NativeModules;
+const deviceInfoEmitter = new NativeEventEmitter(RNDeviceInfo);
+
+type UseReaderSettingsBridgeOptions = {
+ webViewRef: React.RefObject;
+ bottomInset: number;
+ chapterGeneralSettings: ChapterGeneralSettings;
+ readerSettings: ChapterReaderSettings;
+ stopNativePlayback: () => void;
+};
+
+export const useReaderSettingsBridge = ({
+ webViewRef,
+ bottomInset,
+ chapterGeneralSettings,
+ readerSettings,
+ stopNativePlayback,
+}: UseReaderSettingsBridgeOptions) => {
+ const readerSettingsMountedRef = useRef(false);
+ const generalSettingsMountedRef = useRef(false);
+
+ useEffect(() => {
+ if (!readerSettingsMountedRef.current) {
+ readerSettingsMountedRef.current = true;
+ return;
+ }
+ stopNativePlayback();
+ webViewRef.current?.injectJavaScript(`
+ if (window.reader?.readerSettings) {
+ reader.readerSettings.val = ${JSON.stringify(readerSettings)};
+ if (window.tts && tts.reading) {
+ const currentElement = tts.currentElement;
+ tts.stop();
+ setTimeout(() => tts.start(currentElement), 100);
+ }
+ }`);
+ }, [readerSettings, stopNativePlayback, webViewRef]);
+
+ useEffect(() => {
+ if (!generalSettingsMountedRef.current) {
+ generalSettingsMountedRef.current = true;
+ return;
+ }
+ webViewRef.current?.injectJavaScript(`
+ if (window.reader?.generalSettings) {
+ reader.generalSettings.val = ${JSON.stringify(chapterGeneralSettings)};
+ document.documentElement.style.setProperty(
+ '--reader-bottomInset',
+ '${chapterGeneralSettings.fullScreenMode ? 0 : bottomInset}px'
+ );
+ }`);
+ }, [bottomInset, chapterGeneralSettings, webViewRef]);
+
+ useEffect(() => {
+ const batterySubscription = deviceInfoEmitter.addListener(
+ 'RNDeviceInfo_batteryLevelDidChange',
+ (level: number) => {
+ webViewRef.current?.injectJavaScript(`
+ if (window.reader?.batteryLevel) {
+ reader.batteryLevel.val = ${level};
+ }`);
+ },
+ );
+
+ return () => {
+ batterySubscription.remove();
+ };
+ }, [webViewRef]);
+};
diff --git a/src/screens/reader/components/Hooks/useReadingTime.ts b/src/screens/reader/components/Hooks/useReadingTime.ts
new file mode 100644
index 0000000000..8ec7ed2541
--- /dev/null
+++ b/src/screens/reader/components/Hooks/useReadingTime.ts
@@ -0,0 +1,77 @@
+import { addReadDuration } from '@database/queries/ChapterQueries';
+import { useCallback, useEffect, useMemo, useRef } from 'react';
+
+export default function useReadingTime(chapterId: number) {
+ const isTTSReadingRef = useRef(false);
+ const readStartTimeRef = useRef(null);
+ const accumulatedReadTimeRef = useRef(0);
+ const chapterIdRef = useRef(chapterId);
+
+ const start = useCallback(() => {
+ if (readStartTimeRef.current === null && !isTTSReadingRef.current) {
+ readStartTimeRef.current = Date.now();
+ }
+ }, []);
+
+ const pause = useCallback(() => {
+ if (readStartTimeRef.current !== null) {
+ accumulatedReadTimeRef.current += Math.floor(
+ (Date.now() - readStartTimeRef.current) / 1000,
+ );
+ readStartTimeRef.current = null;
+ }
+ }, []);
+
+ const save = useCallback(
+ (id: number) => {
+ pause();
+ const totalSeconds = accumulatedReadTimeRef.current;
+ if (totalSeconds > 0) {
+ addReadDuration(id, totalSeconds).catch(() => {});
+ accumulatedReadTimeRef.current = 0;
+ }
+ },
+ [pause],
+ );
+
+ const setTTSReading = useCallback(
+ (isReading: boolean) => {
+ const wasReading = isTTSReadingRef.current;
+ isTTSReadingRef.current = isReading;
+ if (isReading && !wasReading) {
+ pause();
+ } else if (!isReading && wasReading) {
+ start();
+ }
+ },
+ [pause, start],
+ );
+
+ const stopTTSForBackground = useCallback(() => {
+ isTTSReadingRef.current = false;
+ }, []);
+
+ useEffect(() => {
+ start();
+ return () => save(chapterIdRef.current);
+ }, [save, start]);
+
+ useEffect(() => {
+ if (chapterIdRef.current !== chapterId) {
+ save(chapterIdRef.current);
+ chapterIdRef.current = chapterId;
+ start();
+ }
+ }, [chapterId, save, start]);
+
+ return useMemo(
+ () => ({
+ isTTSReadingRef,
+ start,
+ pause,
+ setTTSReading,
+ stopTTSForBackground,
+ }),
+ [pause, setTTSReading, start, stopTTSForBackground],
+ );
+}
diff --git a/src/screens/reader/components/Hooks/useTTS.ts b/src/screens/reader/components/Hooks/useTTS.ts
new file mode 100644
index 0000000000..7c1535044b
--- /dev/null
+++ b/src/screens/reader/components/Hooks/useTTS.ts
@@ -0,0 +1,381 @@
+import type { ChapterInfo, NovelInfo } from '@database/types';
+import type { ChapterReaderSettings } from '@hooks/persisted/useSettings';
+import { showToast } from '@utils/showToast';
+import {
+ dismissTTSNotification,
+ showTTSNotification,
+ ttsMediaEmitter,
+ updateTTSNotification,
+ updateTTSPlaybackState,
+ updateTTSProgress,
+} from '@utils/ttsNotification';
+import * as Speech from 'expo-speech';
+import { useCallback, useEffect, useMemo, useRef } from 'react';
+import { AppState, NativeEventEmitter, NativeModules } from 'react-native';
+import type WebView from 'react-native-webview';
+
+import type useReadingTime from './useReadingTime';
+import type { WebViewPostEvent } from './webViewEvents';
+
+const { TikTokTTS } = NativeModules;
+const tiktokTTSEmitter = TikTokTTS ? new NativeEventEmitter(TikTokTTS) : null;
+const stopNativeEngines = () => {
+ Speech.stop();
+ TikTokTTS?.stop();
+};
+
+type ReadingTimeController = ReturnType;
+
+type UseTTSOptions = {
+ webViewRef: React.RefObject;
+ novel: NovelInfo;
+ chapter: ChapterInfo;
+ readerSettingsRef: React.RefObject;
+ readingTime: ReadingTimeController;
+};
+
+export default function useTTS({
+ webViewRef,
+ novel,
+ chapter,
+ readerSettingsRef,
+ readingTime,
+}: UseTTSOptions) {
+ const autoStartTTSRef = useRef(false);
+ const ttsQueueRef = useRef([]);
+ const ttsQueueIndexRef = useRef(0);
+ const appStateRef = useRef(AppState.currentState);
+ const autoStartTimerRef = useRef | null>(null);
+ const { isTTSReadingRef } = readingTime;
+ const notificationInfo = useMemo(
+ () => ({
+ novelName: novel.name || 'Unknown',
+ chapterName: chapter.name,
+ coverUri: novel.cover || '',
+ isPlaying: true,
+ }),
+ [chapter.name, novel.cover, novel.name],
+ );
+ const clearAutoStartTimer = useCallback(() => {
+ if (autoStartTimerRef.current) {
+ clearTimeout(autoStartTimerRef.current);
+ autoStartTimerRef.current = null;
+ }
+ }, []);
+ const resetAfterPlaybackError = useCallback(() => {
+ autoStartTTSRef.current = false;
+ clearAutoStartTimer();
+ ttsQueueRef.current = [];
+ ttsQueueIndexRef.current = 0;
+ if (appStateRef.current === 'active') {
+ readingTime.setTTSReading(false);
+ } else {
+ readingTime.stopTTSForBackground();
+ }
+ dismissTTSNotification();
+ webViewRef.current?.injectJavaScript('tts.stop?.()');
+ }, [clearAutoStartTimer, readingTime, webViewRef]);
+
+ useEffect(() => {
+ const inject = (script: string) =>
+ webViewRef.current?.injectJavaScript(script);
+ const listeners = [
+ ttsMediaEmitter.addListener('TTSPlay', () =>
+ inject('if (window.tts && !tts.reading) { tts.resume(); }'),
+ ),
+ ttsMediaEmitter.addListener('TTSPause', () =>
+ inject('if (window.tts && tts.reading) { tts.pause(); }'),
+ ),
+ ttsMediaEmitter.addListener('TTSStop', () =>
+ inject('if (window.tts) { tts.stop(); }'),
+ ),
+ ttsMediaEmitter.addListener('TTSRewind', () =>
+ inject('if (window.tts && tts.started) { tts.rewind(); }'),
+ ),
+ ttsMediaEmitter.addListener('TTSPrev', () =>
+ inject(`
+ if (window.tts && window.reader && window.reader.prevChapter) {
+ window.reader.post({ type: 'prev', autoStartTTS: true });
+ }`),
+ ),
+ ttsMediaEmitter.addListener('TTSNext', () =>
+ inject(`
+ if (window.tts && window.reader && window.reader.nextChapter) {
+ window.reader.post({ type: 'next', autoStartTTS: true });
+ }`),
+ ),
+ ttsMediaEmitter.addListener(
+ 'TTSSeekTo',
+ ({ position }: { position: number }) => {
+ if (Number.isFinite(position)) {
+ inject(
+ `if (window.tts && tts.started) { tts.seekTo(${position}); }`,
+ );
+ }
+ },
+ ),
+ ];
+ return () => listeners.forEach(listener => listener.remove());
+ }, [webViewRef]);
+
+ useEffect(() => {
+ if (isTTSReadingRef.current) {
+ updateTTSNotification(notificationInfo);
+ }
+ }, [isTTSReadingRef, notificationInfo]);
+
+ useEffect(
+ () => () => {
+ clearAutoStartTimer();
+ dismissTTSNotification();
+ },
+ [clearAutoStartTimer],
+ );
+
+ useEffect(() => {
+ const subscription = AppState.addEventListener('change', nextState => {
+ appStateRef.current = nextState;
+ if (nextState === 'active') {
+ readingTime.start();
+ return;
+ }
+
+ readingTime.pause();
+ if (isTTSReadingRef.current) {
+ stopNativeEngines();
+ readingTime.stopTTSForBackground();
+ ttsQueueRef.current = [];
+ ttsQueueIndexRef.current = 0;
+ dismissTTSNotification();
+ webViewRef.current?.injectJavaScript('if (window.tts) { tts.stop(); }');
+ }
+ });
+ return () => subscription.remove();
+ }, [isTTSReadingRef, readingTime, webViewRef]);
+
+ useEffect(() => {
+ if (!tiktokTTSEmitter) return;
+
+ const onStart = tiktokTTSEmitter.addListener('TikTokTTS_onStart', () => {
+ webViewRef.current?.injectJavaScript('tts.setLoading(true)');
+ });
+ const onDone = tiktokTTSEmitter.addListener('TikTokTTS_onDone', () => {
+ webViewRef.current?.injectJavaScript('tts.setLoading(false)');
+ if (appStateRef.current === 'active') {
+ webViewRef.current?.injectJavaScript('tts.next?.()');
+ }
+ });
+ const onError = tiktokTTSEmitter.addListener(
+ 'TikTokTTS_onError',
+ (error: { message?: string }) => {
+ webViewRef.current?.injectJavaScript('tts.setLoading(false)');
+ resetAfterPlaybackError();
+ // eslint-disable-next-line no-console
+ console.error('TikTokTTS Error:', error.message);
+ },
+ );
+ return () => {
+ onStart.remove();
+ onDone.remove();
+ onError.remove();
+ };
+ }, [resetAfterPlaybackError, webViewRef]);
+
+ const speakText = useCallback(
+ (text: string) => {
+ const ttsSettings = readerSettingsRef.current?.tts;
+ if (ttsSettings?.engine === 'tiktok') {
+ const voice = ttsSettings.voice?.identifier;
+ if (!voice) {
+ showToast('TikTok TTS: No voice selected');
+ return;
+ }
+ TikTokTTS?.speak(
+ text,
+ voice,
+ ttsSettings.queueSize || 3,
+ ttsSettings.rate || 1,
+ ttsSettings.pitch || 1,
+ );
+ return;
+ }
+
+ Speech.speak(text, {
+ onDone() {
+ if (appStateRef.current === 'active') {
+ webViewRef.current?.injectJavaScript('tts.next?.()');
+ }
+ },
+ onError: resetAfterPlaybackError,
+ voice: ttsSettings?.voice?.identifier,
+ pitch: ttsSettings?.pitch || 1,
+ rate: ttsSettings?.rate || 1,
+ });
+ },
+ [readerSettingsRef, resetAfterPlaybackError, webViewRef],
+ );
+
+ const handleQueue = useCallback(
+ (event: WebViewPostEvent) => {
+ const payload = event.data as
+ | { queue?: unknown; startIndex?: unknown }
+ | undefined;
+ const queue = Array.isArray(payload?.queue)
+ ? payload.queue.filter(
+ (item): item is string =>
+ typeof item === 'string' && item.trim().length > 0,
+ )
+ : [];
+ ttsQueueRef.current = queue;
+ ttsQueueIndexRef.current =
+ typeof payload?.startIndex === 'number' ? payload.startIndex : 0;
+
+ if (readerSettingsRef.current?.tts?.engine === 'tiktok') {
+ const voice = readerSettingsRef.current.tts.voice?.identifier;
+ if (voice) {
+ TikTokTTS?.updateQueue(queue.slice(ttsQueueIndexRef.current), voice);
+ }
+ }
+ },
+ [readerSettingsRef],
+ );
+
+ const handleSpeak = useCallback(
+ (event: WebViewPostEvent) => {
+ if (typeof event.data !== 'string' || !event.data) {
+ webViewRef.current?.injectJavaScript('tts.next?.()');
+ return;
+ }
+
+ const ttsSettings = readerSettingsRef.current?.tts;
+ if (ttsSettings?.engine === 'tiktok') {
+ if (!TikTokTTS) {
+ showToast('TikTok TTS is unavailable');
+ resetAfterPlaybackError();
+ return;
+ }
+ if (!ttsSettings.voice?.identifier) {
+ showToast('TikTok TTS: No voice selected');
+ resetAfterPlaybackError();
+ return;
+ }
+ }
+
+ if (typeof event.index === 'number') {
+ ttsQueueIndexRef.current = event.index;
+ }
+ if (!isTTSReadingRef.current) {
+ readingTime.setTTSReading(true);
+ showTTSNotification(notificationInfo);
+ } else {
+ updateTTSNotification(notificationInfo);
+ }
+ if (
+ typeof event.index === 'number' &&
+ typeof event.total === 'number' &&
+ event.total > 0
+ ) {
+ updateTTSProgress(event.index, event.total);
+ }
+ if (readerSettingsRef.current?.tts?.engine === 'tiktok') {
+ const voice = readerSettingsRef.current.tts.voice?.identifier;
+ if (voice) {
+ TikTokTTS?.updateQueue(
+ ttsQueueRef.current.slice(ttsQueueIndexRef.current + 1),
+ voice,
+ );
+ }
+ }
+ speakText(event.data);
+ },
+ [
+ isTTSReadingRef,
+ notificationInfo,
+ readerSettingsRef,
+ readingTime,
+ resetAfterPlaybackError,
+ speakText,
+ webViewRef,
+ ],
+ );
+
+ const handlePause = useCallback(() => {
+ Speech.stop();
+ TikTokTTS?.pause();
+ }, []);
+
+ const handleStop = useCallback(() => {
+ stopNativeEngines();
+ if (!autoStartTTSRef.current) {
+ clearAutoStartTimer();
+ readingTime.setTTSReading(false);
+ ttsQueueRef.current = [];
+ ttsQueueIndexRef.current = 0;
+ dismissTTSNotification();
+ }
+ }, [clearAutoStartTimer, readingTime]);
+
+ const handleState = useCallback(
+ (event: WebViewPostEvent) => {
+ if (typeof event.data !== 'object' || event.data === null) return;
+ const isReading =
+ (event.data as { isReading?: boolean }).isReading === true;
+ readingTime.setTTSReading(isReading);
+ updateTTSPlaybackState(isReading);
+ },
+ [readingTime],
+ );
+
+ const stopNativePlayback = useCallback(() => {
+ stopNativeEngines();
+ }, []);
+
+ const scheduleAutoStart = useCallback(() => {
+ clearAutoStartTimer();
+ autoStartTTSRef.current = true;
+ }, [clearAutoStartTimer]);
+
+ const handleLoadEnd = useCallback(() => {
+ if (!autoStartTTSRef.current) return;
+ autoStartTTSRef.current = false;
+ clearAutoStartTimer();
+ autoStartTimerRef.current = setTimeout(() => {
+ autoStartTimerRef.current = null;
+ webViewRef.current?.injectJavaScript(`
+ (function() {
+ if (window.tts && reader.generalSettings.val.TTSEnable) {
+ setTimeout(() => {
+ tts.start();
+ const controller = document.getElementById('TTS-Controller');
+ if (controller && controller.firstElementChild) {
+ controller.firstElementChild.innerHTML = pauseIcon;
+ }
+ }, 500);
+ }
+ })();`);
+ }, 300);
+ }, [clearAutoStartTimer, webViewRef]);
+
+ return useMemo(
+ () => ({
+ handleLoadEnd,
+ handlePause,
+ handleQueue,
+ handleSpeak,
+ handleState,
+ handleStop,
+ scheduleAutoStart,
+ stopNativePlayback,
+ }),
+ [
+ handleLoadEnd,
+ handlePause,
+ handleQueue,
+ handleSpeak,
+ handleState,
+ handleStop,
+ scheduleAutoStart,
+ stopNativePlayback,
+ ],
+ );
+}
diff --git a/src/screens/reader/components/Hooks/webViewEvents.ts b/src/screens/reader/components/Hooks/webViewEvents.ts
new file mode 100644
index 0000000000..3b2e700649
--- /dev/null
+++ b/src/screens/reader/components/Hooks/webViewEvents.ts
@@ -0,0 +1,46 @@
+export type WebViewPostEvent = {
+ type: string;
+ data?: unknown;
+ autoStartTTS?: boolean;
+ index?: number;
+ total?: number;
+ initialScrollPosition?: 'start' | 'end';
+ method?: string;
+ args?: unknown[];
+ msg?: string;
+};
+
+export const parseWebViewEvent = (payload: string): WebViewPostEvent | null => {
+ try {
+ const event: unknown = JSON.parse(payload);
+ if (
+ typeof event === 'object' &&
+ event !== null &&
+ 'type' in event &&
+ typeof event.type === 'string'
+ ) {
+ const value = event as Record;
+ return {
+ type: event.type,
+ data: value.data,
+ autoStartTTS:
+ typeof value.autoStartTTS === 'boolean'
+ ? value.autoStartTTS
+ : undefined,
+ index: typeof value.index === 'number' ? value.index : undefined,
+ total: typeof value.total === 'number' ? value.total : undefined,
+ initialScrollPosition:
+ value.initialScrollPosition === 'start' ||
+ value.initialScrollPosition === 'end'
+ ? value.initialScrollPosition
+ : undefined,
+ method: typeof value.method === 'string' ? value.method : undefined,
+ args: Array.isArray(value.args) ? value.args : undefined,
+ msg: typeof value.msg === 'string' ? value.msg : undefined,
+ };
+ }
+ } catch {
+ // Ignore messages that are not part of the reader bridge protocol.
+ }
+ return null;
+};
diff --git a/src/screens/reader/components/WebViewReader.tsx b/src/screens/reader/components/WebViewReader.tsx
index 8f219b6d9f..69e51952eb 100644
--- a/src/screens/reader/components/WebViewReader.tsx
+++ b/src/screens/reader/components/WebViewReader.tsx
@@ -1,69 +1,36 @@
-import { addReadDuration } from '@database/queries/ChapterQueries';
-import { useTheme } from '@hooks/persisted';
import {
- CHAPTER_GENERAL_SETTINGS,
- CHAPTER_READER_SETTINGS,
+ useChapterGeneralSettings,
+ useChapterReaderSettings,
+ useTheme,
+} from '@hooks/persisted';
+import type {
ChapterGeneralSettings,
ChapterReaderSettings,
- initialChapterGeneralSettings,
- initialChapterReaderSettings,
} from '@hooks/persisted/useSettings';
import { getUserAgent } from '@hooks/persisted/useUserAgent';
import { getLocalServerUrl } from '@plugins/local/localServerManager';
import { getPlugin } from '@plugins/pluginManager';
import { getString } from '@strings/translations';
-import { getMMKVObject, MMKVStorage } from '@utils/mmkv/mmkv';
-import { showToast } from '@utils/showToast';
import { PLUGIN_STORAGE } from '@utils/Storages';
-import {
- dismissTTSNotification,
- showTTSNotification,
- ttsMediaEmitter,
- updateTTSNotification,
- updateTTSPlaybackState,
- updateTTSProgress,
-} from '@utils/ttsNotification';
import * as ScreenOrientation from 'expo-screen-orientation';
-import * as Speech from 'expo-speech';
-import React, {
- memo,
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from 'react';
-import { AppState, NativeEventEmitter, NativeModules } from 'react-native';
+import React, { memo, useEffect, useMemo, useRef } from 'react';
import { getBatteryLevelSync } from 'react-native-device-info';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
import WebView from 'react-native-webview';
import { useChapterContext } from '../ChapterContext';
import type { NativeFindResult } from '../hooks/useNativeChapterSearch';
import { generateReaderHtml } from '../utils/htmlGenerator';
-
-type WebViewPostEvent = {
- type: string;
- data?: { [key: string]: unknown };
- autoStartTTS?: boolean;
- index?: number;
- total?: number;
- initialScrollPosition?: 'start' | 'end';
- // console/error log payloads
- method?: string;
- args?: unknown[];
- msg?: string;
-};
+import useReaderMessageHandler from './Hooks/useReaderMessageHandler';
+import { useReaderSettingsBridge } from './Hooks/useReaderSettings';
+import useReadingTime from './Hooks/useReadingTime';
+import useTTS from './Hooks/useTTS';
type WebViewReaderProps = {
onPress(): void;
onFindResult(result: NativeFindResult): void;
+ bottomInset: number;
};
-const { RNDeviceInfo, TikTokTTS } = NativeModules;
-const deviceInfoEmitter = new NativeEventEmitter(RNDeviceInfo);
-const tiktokTTSEmitter = new NativeEventEmitter(TikTokTTS);
-
const assetsUriPrefix = __DEV__
? 'http://localhost:8081/assets'
: 'file:///android_asset';
@@ -71,6 +38,7 @@ const assetsUriPrefix = __DEV__
const WebViewReader: React.FC = ({
onPress,
onFindResult,
+ bottomInset,
}) => {
const {
novel,
@@ -85,379 +53,65 @@ const WebViewReader: React.FC = ({
refetch,
} = useChapterContext();
const theme = useTheme();
- const { bottom } = useSafeAreaInsets();
- // Use state for settings so they update when MMKV changes
- const [readerSettings, setReaderSettings] = useState(
- () =>
- getMMKVObject(CHAPTER_READER_SETTINGS) ||
- initialChapterReaderSettings,
- );
- const chapterGeneralSettings = useMemo(() => {
- // eslint-disable-next-line no-void
- void chapter.id; // fix eslint warning: react-hooks/exhaustive-deps
- return (
- getMMKVObject(CHAPTER_GENERAL_SETTINGS) ||
- initialChapterGeneralSettings
- );
- }, [chapter.id]);
- const readerBottomInset = chapterGeneralSettings.fullScreenMode ? 0 : bottom;
-
- // Update readerSettings when chapter changes
- useEffect(() => {
- setReaderSettings(
- getMMKVObject(CHAPTER_READER_SETTINGS) ||
- initialChapterReaderSettings,
- );
- }, [chapter.id]);
-
- // Update battery level when chapter changes to ensure fresh value on navigation
- const batteryLevel = useMemo(() => getBatteryLevelSync(), []);
- const plugin = getPlugin(novel?.pluginId);
- const pluginCustomJS = `file://${PLUGIN_STORAGE}/${plugin?.id}/custom.js`;
- const pluginCustomCSS = `file://${PLUGIN_STORAGE}/${plugin?.id}/custom.css`;
- const nextChapterScreenVisible = useRef(false);
- const pendingScrollPositionRef = useRef<'start' | 'end' | null>(null);
- const autoStartTTSRef = useRef(false);
- const isTTSReadingRef = useRef(false);
+ const readerSettings = useChapterReaderSettings();
+ const chapterGeneralSettings = useChapterGeneralSettings();
const readerSettingsRef = useRef(readerSettings);
- const appStateRef = useRef(AppState.currentState);
- const ttsQueueRef = useRef([]);
- const ttsQueueIndexRef = useRef(0);
-
- // --- Reading time tracking ---
- const readStartTimeRef = useRef(null);
- const accumulatedReadTimeRef = useRef(0);
- const chapterIdForReadTimeRef = useRef(chapter.id);
-
- // Start reading timer
- const startReadTimer = useCallback(() => {
- if (!readStartTimeRef.current && !isTTSReadingRef.current) {
- readStartTimeRef.current = Date.now();
- }
- }, []);
-
- // Pause reading timer and accumulate
- const pauseReadTimer = useCallback(() => {
- if (readStartTimeRef.current) {
- const elapsed = Math.floor(
- (Date.now() - readStartTimeRef.current) / 1000,
- );
- accumulatedReadTimeRef.current += elapsed;
- readStartTimeRef.current = null;
- }
- }, []);
-
- // Save accumulated reading time to DB and reset
- const saveReadTime = useCallback(
- (chId: number) => {
- pauseReadTimer();
- const totalSeconds = accumulatedReadTimeRef.current;
- if (totalSeconds > 0) {
- addReadDuration(chId, totalSeconds).catch(() => {});
- accumulatedReadTimeRef.current = 0;
- }
- },
- [pauseReadTimer],
+ const chapterGeneralSettingsRef = useRef(
+ chapterGeneralSettings,
);
+ const readingTime = useReadingTime(chapter.id);
+ const tts = useTTS({
+ webViewRef,
+ novel,
+ chapter,
+ readerSettingsRef,
+ readingTime,
+ });
- // Start timer on mount
- useEffect(() => {
- startReadTimer();
- return () => {
- // Save on unmount (leaving reader)
- saveReadTime(chapterIdForReadTimeRef.current);
- };
- }, [startReadTimer, saveReadTime]);
-
- // Track chapter changes — save read time for previous chapter
- useEffect(() => {
- if (chapterIdForReadTimeRef.current !== chapter.id) {
- saveReadTime(chapterIdForReadTimeRef.current);
- chapterIdForReadTimeRef.current = chapter.id;
- startReadTimer();
- }
- }, [chapter.id, saveReadTime, startReadTimer]);
+ useReaderSettingsBridge({
+ webViewRef,
+ bottomInset,
+ chapterGeneralSettings,
+ readerSettings,
+ stopNativePlayback: tts.stopNativePlayback,
+ });
useEffect(() => {
readerSettingsRef.current = readerSettings;
- }, [readerSettings]);
-
- useEffect(() => {
- const playListener = ttsMediaEmitter.addListener('TTSPlay', () => {
- webViewRef.current?.injectJavaScript(`
- if (window.tts && !tts.reading) { tts.resume(); }
- `);
- });
- const pauseListener = ttsMediaEmitter.addListener('TTSPause', () => {
- webViewRef.current?.injectJavaScript(`
- if (window.tts && tts.reading) { tts.pause(); }
- `);
- });
- const stopListener = ttsMediaEmitter.addListener('TTSStop', () => {
- webViewRef.current?.injectJavaScript(`
- if (window.tts) { tts.stop(); }
- `);
- });
- const rewindListener = ttsMediaEmitter.addListener('TTSRewind', () => {
- webViewRef.current?.injectJavaScript(`
- if (window.tts && tts.started) { tts.rewind(); }
- `);
- });
- const prevListener = ttsMediaEmitter.addListener('TTSPrev', () => {
- webViewRef.current?.injectJavaScript(`
- if (window.tts && window.reader && window.reader.prevChapter) {
- window.reader.post({ type: 'prev', autoStartTTS: true });
- }
- `);
- });
- const nextListener = ttsMediaEmitter.addListener('TTSNext', () => {
- webViewRef.current?.injectJavaScript(`
- if (window.tts && window.reader && window.reader.nextChapter) {
- window.reader.post({ type: 'next', autoStartTTS: true });
- }
- `);
- });
- const seekToListener = ttsMediaEmitter.addListener(
- 'TTSSeekTo',
- (event: { position: number }) => {
- const { position } = event;
- webViewRef.current?.injectJavaScript(`
- if (window.tts && tts.started) { tts.seekTo(${position}); }
- `);
- },
- );
- return () => {
- playListener.remove();
- pauseListener.remove();
- stopListener.remove();
- rewindListener.remove();
- prevListener.remove();
- nextListener.remove();
- seekToListener.remove();
- };
- }, [webViewRef]);
-
- useEffect(() => {
- if (isTTSReadingRef.current) {
- updateTTSNotification({
- novelName: novel?.name || 'Unknown',
- chapterName: chapter.name,
- coverUri: novel?.cover || '',
- isPlaying: isTTSReadingRef.current,
- });
- }
- }, [novel?.name, novel?.cover, chapter.name]);
-
- useEffect(() => {
- return () => {
- dismissTTSNotification();
- ScreenOrientation.unlockAsync();
- };
- }, []);
-
- useEffect(() => {
- const mmkvListener = MMKVStorage.addOnValueChangedListener(key => {
- switch (key) {
- case CHAPTER_READER_SETTINGS:
- // Update local state with new settings
- const newSettings =
- getMMKVObject(CHAPTER_READER_SETTINGS) ||
- initialChapterReaderSettings;
- setReaderSettings(newSettings);
-
- // Stop any currently playing speech
- Speech.stop();
- TikTokTTS?.stop();
-
- // Update WebView settings
- webViewRef.current?.injectJavaScript(
- `
- reader.readerSettings.val = ${MMKVStorage.getString(
- CHAPTER_READER_SETTINGS,
- )};
- // Auto-restart TTS if currently reading
- if (window.tts && tts.reading) {
- const currentElement = tts.currentElement;
- const wasReading = tts.reading;
- tts.stop();
- if (wasReading) {
- setTimeout(() => {
- tts.start(currentElement);
- }, 100);
- }
- }
- `,
- );
- break;
- case CHAPTER_GENERAL_SETTINGS:
- const newGeneralSettings =
- getMMKVObject(CHAPTER_GENERAL_SETTINGS) ||
- initialChapterGeneralSettings;
- webViewRef.current?.injectJavaScript(
- `reader.generalSettings.val = ${MMKVStorage.getString(
- CHAPTER_GENERAL_SETTINGS,
- )};
- document.documentElement.style.setProperty('--reader-bottomInset', '${
- newGeneralSettings.fullScreenMode ? 0 : bottom
- }px');`,
- );
- break;
- }
- });
-
- const subscription = deviceInfoEmitter.addListener(
- 'RNDeviceInfo_batteryLevelDidChange',
- (level: number) => {
- webViewRef.current?.injectJavaScript(
- `reader.batteryLevel.val = ${level}`,
- );
- },
- );
- return () => {
- subscription.remove();
- mmkvListener.remove();
- };
- }, [bottom, webViewRef]);
-
- useEffect(() => {
- const subscription = AppState.addEventListener('change', nextState => {
- appStateRef.current = nextState;
- if (nextState === 'active') {
- // Resume reading timer (only if not TTS)
- if (!isTTSReadingRef.current) {
- startReadTimer();
- }
- if (isTTSReadingRef.current) {
- const index = ttsQueueIndexRef.current;
- webViewRef.current?.injectJavaScript(`
- if (window.tts && window.tts.allReadableElements) {
- const idx = ${index};
- if (idx < tts.allReadableElements.length) {
- if (tts.currentElement) {
- tts.currentElement.classList.remove('highlight');
- }
- tts.elementsRead = idx;
- tts.currentElement = tts.allReadableElements[idx];
- tts.prevElement = null;
- tts.started = true;
- tts.reading = true;
- tts.scrollToElement(tts.currentElement);
- tts.currentElement.classList.add('highlight');
- }
- }
- `);
- }
- } else {
- // Pause reading timer on background/inactive
- pauseReadTimer();
-
- // Stop TTS on background to prevent sync issues
- if (isTTSReadingRef.current) {
- Speech.stop();
- TikTokTTS?.stop();
- isTTSReadingRef.current = false;
- ttsQueueRef.current = [];
- ttsQueueIndexRef.current = 0;
- dismissTTSNotification();
- webViewRef.current?.injectJavaScript(`
- if (window.tts) { tts.stop(); }
- `);
- }
- }
- });
-
- return () => subscription.remove();
- }, [webViewRef, startReadTimer, pauseReadTimer]);
+ chapterGeneralSettingsRef.current = chapterGeneralSettings;
+ }, [chapterGeneralSettings, readerSettings]);
- useEffect(() => {
- if (!TikTokTTS) {
- return;
- }
-
- const onStart = tiktokTTSEmitter.addListener('TikTokTTS_onStart', () => {
- webViewRef.current?.injectJavaScript('tts.setLoading(true)');
- });
- const onDone = tiktokTTSEmitter.addListener('TikTokTTS_onDone', () => {
- webViewRef.current?.injectJavaScript('tts.setLoading(false)');
- const isBackground =
- appStateRef.current === 'background' ||
- appStateRef.current === 'inactive';
-
- /*
- if (
- isBackground &&
- ttsQueueRef.current.length > 0 &&
- ttsQueueIndexRef.current + 1 < ttsQueueRef.current.length
- ) {
- const nextIndex = ttsQueueIndexRef.current + 1;
- const nextText = ttsQueueRef.current[nextIndex];
- if (nextText) {
- ttsQueueIndexRef.current = nextIndex;
- speakText(nextText);
- return;
- }
- }
-
- if (isBackground) {
- isTTSReadingRef.current = false;
- dismissTTSNotification();
- webViewRef.current?.injectJavaScript('tts.stop?.()');
- return;
- }
- */
-
- if (isBackground) {
- return;
- }
-
- webViewRef.current?.injectJavaScript('tts.next?.()');
- });
- const onError = tiktokTTSEmitter.addListener('TikTokTTS_onError', err => {
- webViewRef.current?.injectJavaScript('tts.setLoading(false)');
- // eslint-disable-next-line no-console
- console.error('TikTokTTS Error:', err.message);
- });
-
- return () => {
- onStart.remove();
- onDone.remove();
- onError.remove();
- };
- }, [webViewRef]);
-
- const speakText = (text: string) => {
- if (readerSettingsRef.current.tts?.engine === 'tiktok') {
- const voice = readerSettingsRef.current.tts?.voice?.identifier;
- if (!voice) {
- // Voice must be selected for TikTok TTS
- showToast('TikTok TTS: No voice selected');
- return;
- }
- const queueSize = readerSettingsRef.current.tts?.queueSize || 3;
- const rate = readerSettingsRef.current.tts?.rate || 1;
- const pitch = readerSettingsRef.current.tts?.pitch || 1;
- TikTokTTS.speak(text, voice, queueSize, rate, pitch);
- return;
- }
- Speech.speak(text, {
- onDone() {
- const isBackground =
- appStateRef.current === 'background' ||
- appStateRef.current === 'inactive';
+ useEffect(
+ () => () => {
+ ScreenOrientation.unlockAsync().catch(() => {});
+ },
+ [],
+ );
- if (isBackground) {
- return;
- }
+ const batteryLevel = useMemo(() => getBatteryLevelSync(), []);
+ const plugin = getPlugin(novel.pluginId);
+ const pluginCustomJS = `file://${PLUGIN_STORAGE}/${plugin?.id}/custom.js`;
+ const pluginCustomCSS = `file://${PLUGIN_STORAGE}/${plugin?.id}/custom.css`;
+ const readerDir =
+ plugin?.lang === 'Arabic' || plugin?.lang === 'Hebrew' ? 'rtl' : 'ltr';
+ const readerBottomInset = chapterGeneralSettingsRef.current.fullScreenMode
+ ? 0
+ : bottomInset;
- webViewRef.current?.injectJavaScript('tts.next?.()');
- },
- voice: readerSettingsRef.current.tts?.voice?.identifier,
- pitch: readerSettingsRef.current.tts?.pitch || 1,
- rate: readerSettingsRef.current.tts?.rate || 1,
- });
- };
- const isRTL = plugin?.lang === 'Arabic' || plugin?.lang === 'Hebrew';
- const readerDir = isRTL ? 'rtl' : 'ltr';
+ const {
+ clearPendingScrollPosition,
+ getNextChapterScreenVisible,
+ getPendingScrollPosition,
+ handleMessage,
+ } = useReaderMessageHandler({
+ onPress,
+ onFindResult,
+ navigateChapter,
+ saveProgress,
+ resetAutoScroll,
+ refetch,
+ tts,
+ });
const source = useMemo(
() => ({
@@ -474,7 +128,7 @@ const WebViewReader: React.FC = ({
theme,
readerDir,
readerSettings: readerSettingsRef.current,
- chapterGeneralSettings,
+ chapterGeneralSettings: chapterGeneralSettingsRef.current,
novel,
chapter,
nextChapter,
@@ -484,8 +138,8 @@ const WebViewReader: React.FC = ({
readerBottomInset,
pluginCustomCSS,
pluginCustomJS,
- nextChapterScreenVisible: nextChapterScreenVisible.current,
- pendingScrollPosition: pendingScrollPositionRef.current,
+ nextChapterScreenVisible: getNextChapterScreenVisible(),
+ pendingScrollPosition: getPendingScrollPosition(),
getLocalServerUrl,
isSettingsPreview: false,
strings: {
@@ -500,20 +154,23 @@ const WebViewReader: React.FC = ({
}),
}),
[
- novel,
+ batteryLevel,
chapter,
+ chapterGeneralSettingsRef,
+ getNextChapterScreenVisible,
+ getPendingScrollPosition,
html,
- theme,
- readerDir,
- plugin?.site,
- plugin?.imageRequestInit,
- readerBottomInset,
- batteryLevel,
- chapterGeneralSettings,
nextChapter,
- prevChapter,
+ novel,
+ plugin?.imageRequestInit,
+ plugin?.site,
pluginCustomCSS,
pluginCustomJS,
+ prevChapter,
+ readerBottomInset,
+ readerDir,
+ readerSettingsRef,
+ theme,
],
);
@@ -521,233 +178,27 @@ const WebViewReader: React.FC = ({
{
- // Update battery level when WebView finishes loading
const currentBatteryLevel = getBatteryLevelSync();
- webViewRef.current?.injectJavaScript(
- `if (window.reader && window.reader.batteryLevel) {
+ webViewRef.current?.injectJavaScript(`
+ if (window.reader && window.reader.batteryLevel) {
window.reader.batteryLevel.val = ${currentBatteryLevel};
- }`,
- );
-
- if (pendingScrollPositionRef.current) {
- pendingScrollPositionRef.current = null; // Reset after first load
- }
-
- if (autoStartTTSRef.current) {
- autoStartTTSRef.current = false;
- setTimeout(() => {
- webViewRef.current?.injectJavaScript(`
- (function() {
- if (window.tts && reader.generalSettings.val.TTSEnable) {
- setTimeout(() => {
- tts.start();
- const controller = document.getElementById('TTS-Controller');
- if (controller && controller.firstElementChild) {
- controller.firstElementChild.innerHTML = pauseIcon;
- }
- }, 500);
- }
- })();
- `);
- }, 300);
- }
- }}
- onMessage={(ev: { nativeEvent: { data: string } }) => {
- let event: WebViewPostEvent;
- try {
- event = JSON.parse(ev.nativeEvent.data);
- } catch {
- // Non-JSON / unparseable message
- return;
- }
- switch (event.type) {
- case 'user-interaction':
- resetAutoScroll();
- break;
- case 'tts-queue': {
- const payload = event.data as
- | { queue?: unknown; startIndex?: unknown }
- | undefined;
- const queue = Array.isArray(payload?.queue)
- ? payload?.queue.filter(
- (item): item is string =>
- typeof item === 'string' && item.trim().length > 0,
- )
- : [];
- ttsQueueRef.current = queue;
- if (typeof payload?.startIndex === 'number') {
- ttsQueueIndexRef.current = payload.startIndex;
- } else {
- ttsQueueIndexRef.current = 0;
- }
- if (readerSettingsRef.current.tts?.engine === 'tiktok') {
- const voice = readerSettingsRef.current.tts?.voice?.identifier;
- if (voice) {
- TikTokTTS?.updateQueue(
- queue.slice(ttsQueueIndexRef.current),
- voice,
- );
- }
- }
- break;
- }
- case 'hide':
- onPress();
- break;
- case 'next':
- nextChapterScreenVisible.current = true;
- if (event.initialScrollPosition) {
- pendingScrollPositionRef.current = event.initialScrollPosition;
- }
- if (event.autoStartTTS) {
- autoStartTTSRef.current = true;
- }
- navigateChapter('NEXT');
- break;
- case 'prev':
- if (event.initialScrollPosition) {
- pendingScrollPositionRef.current = event.initialScrollPosition;
- }
- if (event.autoStartTTS) {
- autoStartTTSRef.current = true;
- }
- navigateChapter('PREV');
- break;
- case 'save':
- if (event.data && typeof event.data === 'number') {
- saveProgress(event.data);
- }
- break;
- case 'find-result': {
- const { data } = event;
- if (
- typeof data?.query === 'string' &&
- typeof data.activeMatchOrdinal === 'number' &&
- typeof data.numberOfMatches === 'number' &&
- typeof data.isDoneCounting === 'boolean'
- ) {
- onFindResult({
- query: data.query,
- current:
- data.numberOfMatches > 0 ? data.activeMatchOrdinal + 1 : 0,
- total: data.numberOfMatches,
- isDoneCounting: data.isDoneCounting,
- });
- }
- break;
- }
- case 'speak':
- if (event.data && typeof event.data === 'string') {
- if (typeof event.index === 'number') {
- ttsQueueIndexRef.current = event.index;
- }
- if (!isTTSReadingRef.current) {
- isTTSReadingRef.current = true;
- pauseReadTimer(); // Stop counting reading time during TTS
- showTTSNotification({
- novelName: novel?.name || 'Unknown',
- chapterName: chapter.name,
- coverUri: novel?.cover || '',
- isPlaying: true,
- });
- } else {
- updateTTSNotification({
- novelName: novel?.name || 'Unknown',
- chapterName: chapter.name,
- coverUri: novel?.cover || '',
- isPlaying: true,
- });
- }
- if (
- typeof event.index === 'number' &&
- typeof event.total === 'number' &&
- event.total > 0
- ) {
- updateTTSProgress(event.index, event.total);
- }
- if (readerSettingsRef.current.tts?.engine === 'tiktok') {
- const voice = readerSettingsRef.current.tts?.voice?.identifier;
- if (voice) {
- TikTokTTS?.updateQueue(
- ttsQueueRef.current.slice(ttsQueueIndexRef.current + 1),
- voice,
- );
- }
- }
- speakText(event.data);
- } else {
- webViewRef.current?.injectJavaScript('tts.next?.()');
- }
- break;
- case 'pause-speak':
- Speech.stop();
- TikTokTTS?.pause();
- break;
- case 'stop-speak':
- Speech.stop();
- TikTokTTS?.stop();
- if (!autoStartTTSRef.current) {
- isTTSReadingRef.current = false;
- ttsQueueRef.current = [];
- ttsQueueIndexRef.current = 0;
- dismissTTSNotification();
- startReadTimer(); // Resume reading time tracking
- }
- break;
- case 'tts-state':
- if (event.data && typeof event.data === 'object') {
- const data = event.data as { isReading?: boolean };
- const isReading = data.isReading === true;
- const wasReading = isTTSReadingRef.current;
- isTTSReadingRef.current = isReading;
- updateTTSPlaybackState(isReading);
- // Toggle reading timer based on TTS state
- if (isReading && !wasReading) {
- pauseReadTimer();
- } else if (!isReading && wasReading) {
- startReadTimer();
- }
- }
- break;
- case 'refetch':
- refetch();
- break;
- case 'video-fullscreen-enter':
- ScreenOrientation.lockAsync(
- ScreenOrientation.OrientationLock.LANDSCAPE,
- );
- break;
- case 'video-fullscreen-exit':
- ScreenOrientation.unlockAsync();
- break;
- case 'console':
- // eslint-disable-next-line no-console
- console[(event.method as 'log') ?? 'log'](
- `[WebView]`,
- ...(event.args ?? []),
- );
- break;
- case 'error':
- // eslint-disable-next-line no-console
- console.error(`[WebView Error]`, event.msg);
- break;
- default: {
- // eslint-disable-next-line no-console
- console.warn(`Unknown event: ${event.type}`, event);
- break;
- }
+ }`);
+ if (getPendingScrollPosition()) {
+ clearPendingScrollPosition();
}
+ tts.handleLoadEnd();
}}
+ onMessage={event => handleMessage(event.nativeEvent.data)}
source={source}
/>
);
From aaa806b23d3d384c1dcf83ad904384337f2b6086 Mon Sep 17 00:00:00 2001
From: Elysia <71698422+aiko-chan-ai@users.noreply.github.com>
Date: Fri, 17 Jul 2026 18:52:38 +0700
Subject: [PATCH 3/7] refactor(ui): modularize keyboard-aware form modals
- add reusable KeyboardAwareModal primitive
- support async confirmation, loading and validation
- migrate common form modals to the new API
- prevent duplicate submissions and redundant dismissals
- remove unused TextInput and keyboard height hook
- add modal and category form tests
Co-authored-by: OpenAI Codex
---
.../Modal/KeyboardAvoidingModal.tsx | 225 ++++++--------
src/components/Modal/KeyboardAwareModal.tsx | 162 ++++++++++
.../__tests__/KeyboardAvoidingModal.test.tsx | 233 +++++++++++++++
.../__tests__/KeyboardAwareModal.test.tsx | 122 ++++++++
.../Modal/useKeyboardModalDismiss.ts | 10 +
src/components/TextInput/index.tsx | 71 -----
src/components/index.ts | 5 +-
src/hooks/common/useKeyboardHeight.ts | 31 --
.../components/AddCategoryModal.tsx | 93 ++----
.../__tests__/AddCategoryModal.test.tsx | 186 ++++++++++++
.../novel/components/EditInfoModal.tsx | 281 +++++++++---------
.../Tracker/SetTrackChaptersDialog.tsx | 60 ++--
.../Tracker/SetTrackScoreDialog.tsx | 33 +-
.../components/AddRepositoryModal.tsx | 62 ++--
.../settings/components/ConnectionModal.tsx | 74 ++---
15 files changed, 1060 insertions(+), 588 deletions(-)
create mode 100644 src/components/Modal/KeyboardAwareModal.tsx
create mode 100644 src/components/Modal/__tests__/KeyboardAvoidingModal.test.tsx
create mode 100644 src/components/Modal/__tests__/KeyboardAwareModal.test.tsx
create mode 100644 src/components/Modal/useKeyboardModalDismiss.ts
delete mode 100644 src/components/TextInput/index.tsx
delete mode 100644 src/hooks/common/useKeyboardHeight.ts
create mode 100644 src/screens/Categories/components/__tests__/AddCategoryModal.test.tsx
diff --git a/src/components/Modal/KeyboardAvoidingModal.tsx b/src/components/Modal/KeyboardAvoidingModal.tsx
index e47e0a2e6b..728eadb5bf 100644
--- a/src/components/Modal/KeyboardAvoidingModal.tsx
+++ b/src/components/Modal/KeyboardAvoidingModal.tsx
@@ -1,159 +1,128 @@
import Button from '@components/Button/Button';
-import { useTheme } from '@hooks/persisted';
import { getString } from '@strings/translations';
-import { ThemeColors } from '@theme/types';
-import React from 'react';
-import {
- Keyboard,
- ScrollView,
- StyleSheet,
- Text,
- useWindowDimensions,
- View,
-} from 'react-native';
-import { useAnimatedKeyboard } from 'react-native-keyboard-controller';
-import { Modal, ModalProps, overlay, Portal } from 'react-native-paper';
-import Animated, {
- FadeIn,
- FadeOut,
- useAnimatedStyle,
- withClamp,
- withTiming,
-} from 'react-native-reanimated';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import React, { ReactNode, useEffect, useRef, useState } from 'react';
+import { StyleSheet, View } from 'react-native';
-const MODAL_MARGIN = 24;
-const BORDER_RADIUS = 28;
+import KeyboardAwareModal, {
+ KeyboardAwareModalProps,
+} from './KeyboardAwareModal';
+import useKeyboardModalDismiss from './useKeyboardModalDismiss';
-const getModalTitleColor = (theme: ThemeColors) => ({
- color: theme.onSurface,
-});
+type ConfirmResult = boolean | void;
-export type DefaultModalProps = {
- title: string;
- onSave: () => boolean;
- onDismiss: () => void;
+export type KeyboardAvoidingModalProps = {
+ title: ReactNode;
+ onConfirm: () => ConfirmResult | Promise;
onCancel?: () => void;
onReset?: () => void;
-} & Omit;
+ confirmLabel?: string;
+ cancelLabel?: string;
+ resetLabel?: string;
+ confirmDisabled?: boolean;
+ confirmLoading?: boolean;
+} & Omit;
-const KeyboardAvoidingModal: React.FC = ({
+const KeyboardAvoidingModal: React.FC = ({
visible,
- onDismiss: _onDismiss,
- onSave,
+ title,
+ onDismiss,
+ onConfirm,
onCancel,
onReset,
- title,
- children,
+ confirmLabel = getString('common.save'),
+ cancelLabel = getString('common.cancel'),
+ resetLabel = getString('common.reset'),
+ confirmDisabled = false,
+ confirmLoading = false,
+ dismissable,
+ dismissableBackButton,
...props
}) => {
- const theme = useTheme();
- const insets = useSafeAreaInsets();
- const { height: windowHeight } = useWindowDimensions();
- const keyboard = useAnimatedKeyboard();
+ const [isConfirming, setIsConfirming] = useState(false);
+ const confirmingRef = useRef(false);
+ const confirmRunRef = useRef(0);
+ const busy = confirmLoading || isConfirming;
+ const dismiss = useKeyboardModalDismiss(onDismiss);
- const onDismiss = () => {
- Keyboard.dismiss();
- _onDismiss?.();
- };
+ useEffect(() => {
+ if (!visible) {
+ confirmRunRef.current += 1;
+ confirmingRef.current = false;
+ setIsConfirming(false);
+ }
+ }, [visible]);
- const dismiss = (cb?: () => void | boolean) => {
- if (cb?.() === false) return;
- onDismiss();
- };
+ const handleConfirm = async () => {
+ if (confirmLoading || confirmingRef.current) return;
- const default_availableHeight = windowHeight - insets.top;
- const animatedContainerStyle = useAnimatedStyle(() => {
- const kb = keyboard.height.value;
+ confirmingRef.current = true;
+ setIsConfirming(true);
+ const runId = ++confirmRunRef.current;
+ let dismissed = false;
- const availableHeight =
- default_availableHeight - Math.max(insets.bottom, kb);
- return {
- maxHeight: withClamp(
- { min: 200 },
- withTiming(availableHeight, { duration: 0 }),
- ),
- marginBottom: kb,
- };
- }, [insets.bottom, default_availableHeight]);
+ try {
+ const result = await onConfirm();
+ if (runId !== confirmRunRef.current) return;
- return (
-
-
-
-
- {title}
-
+ if (result !== false) {
+ dismissed = true;
+ dismiss();
+ }
+ } finally {
+ if (runId === confirmRunRef.current && !dismissed) {
+ confirmingRef.current = false;
+ setIsConfirming(false);
+ }
+ }
+ };
-
-
- {children}
-
-
+ const modalDismissable = (dismissable ?? true) && !busy;
+ const modalDismissableBackButton =
+ (dismissableBackButton ?? dismissable ?? true) && !busy;
-
- {onReset ? (
-
- ) : null}
+ return (
+
+ {onReset ? (
+
+ ) : null}
-
+
-
-
-
-
-
-
+
+
+
+ }
+ />
);
};
export default KeyboardAvoidingModal;
const styles = StyleSheet.create({
- modalWrapper: {
- justifyContent: 'center',
- paddingHorizontal: MODAL_MARGIN,
- },
- modalContainer: {
- borderRadius: BORDER_RADIUS,
- shadowColor: 'transparent',
- },
- modalTitle: {
- fontSize: 24,
- lineHeight: 24,
- padding: 24,
- },
- body: {
- flexShrink: 1,
- minHeight: 0,
- },
- content: {
- paddingBottom: 16,
- paddingHorizontal: 24,
- },
buttonRow: {
alignItems: 'center',
flexDirection: 'row',
diff --git a/src/components/Modal/KeyboardAwareModal.tsx b/src/components/Modal/KeyboardAwareModal.tsx
new file mode 100644
index 0000000000..f877d64b49
--- /dev/null
+++ b/src/components/Modal/KeyboardAwareModal.tsx
@@ -0,0 +1,162 @@
+import { useTheme } from '@hooks/persisted';
+import { ThemeColors } from '@theme/types';
+import React, { ReactNode } from 'react';
+import {
+ ScrollView,
+ ScrollViewProps,
+ StyleProp,
+ StyleSheet,
+ Text,
+ useWindowDimensions,
+ View,
+ ViewStyle,
+} from 'react-native';
+import { useAnimatedKeyboard } from 'react-native-keyboard-controller';
+import { Modal, ModalProps, overlay, Portal } from 'react-native-paper';
+import Animated, {
+ FadeIn,
+ FadeOut,
+ useAnimatedStyle,
+} from 'react-native-reanimated';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import useKeyboardModalDismiss from './useKeyboardModalDismiss';
+
+const MODAL_MARGIN = 24;
+const BORDER_RADIUS = 28;
+
+const getModalTitleColor = (theme: ThemeColors) => ({
+ color: theme.onSurface,
+});
+
+export type KeyboardAwareModalProps = {
+ title?: ReactNode;
+ footer?: ReactNode;
+ children?: ReactNode;
+ onDismiss: () => void;
+ scrollable?: boolean;
+ containerStyle?: StyleProp;
+ contentContainerStyle?: StyleProp;
+ scrollViewProps?: Omit;
+} & Omit<
+ ModalProps,
+ 'children' | 'contentContainerStyle' | 'onDismiss' | 'theme'
+>;
+
+const KeyboardAwareModal: React.FC = ({
+ visible,
+ onDismiss: onDismissProp,
+ title,
+ footer,
+ children,
+ scrollable = true,
+ containerStyle,
+ contentContainerStyle,
+ scrollViewProps,
+ style,
+ ...props
+}) => {
+ const theme = useTheme();
+ const insets = useSafeAreaInsets();
+ const { height: windowHeight } = useWindowDimensions();
+ const keyboard = useAnimatedKeyboard();
+
+ const dismiss = useKeyboardModalDismiss(onDismissProp);
+
+ const animatedContainerStyle = useAnimatedStyle(() => {
+ const keyboardHeight = Math.max(0, keyboard.height.value);
+ const availableHeight =
+ windowHeight - insets.top - Math.max(insets.bottom, keyboardHeight);
+
+ return {
+ maxHeight: Math.max(0, availableHeight),
+ marginBottom: keyboardHeight,
+ };
+ }, [insets.bottom, insets.top, windowHeight]);
+
+ return (
+
+
+
+ {title == null ? null : (
+
+ {typeof title === 'string' || typeof title === 'number' ? (
+
+ {title}
+
+ ) : (
+ title
+ )}
+
+ )}
+
+
+ {scrollable ? (
+
+ {children}
+
+ ) : (
+
+ {children}
+
+ )}
+
+
+ {footer}
+
+
+
+ );
+};
+
+export default KeyboardAwareModal;
+
+const styles = StyleSheet.create({
+ modalWrapper: {
+ justifyContent: 'center',
+ paddingHorizontal: MODAL_MARGIN,
+ },
+ modalContainer: {
+ borderRadius: BORDER_RADIUS,
+ shadowColor: 'transparent',
+ },
+ titleContainer: {
+ padding: 24,
+ },
+ modalTitle: {
+ fontSize: 24,
+ lineHeight: 24,
+ },
+ body: {
+ flexShrink: 1,
+ minHeight: 0,
+ },
+ content: {
+ paddingBottom: 16,
+ paddingHorizontal: 24,
+ },
+});
diff --git a/src/components/Modal/__tests__/KeyboardAvoidingModal.test.tsx b/src/components/Modal/__tests__/KeyboardAvoidingModal.test.tsx
new file mode 100644
index 0000000000..076838b02d
--- /dev/null
+++ b/src/components/Modal/__tests__/KeyboardAvoidingModal.test.tsx
@@ -0,0 +1,233 @@
+import { act, render, screen } from '@testing-library/react-native';
+import React from 'react';
+
+import KeyboardAvoidingModal from '../KeyboardAvoidingModal';
+
+const mockButtonPresses = new Map unknown>();
+
+type MockButtonProps = {
+ children: React.ReactNode;
+ disabled?: boolean;
+ loading?: boolean;
+ onPress?: () => unknown;
+};
+
+type MockKeyboardAwareModalProps = {
+ children?: React.ReactNode;
+ dismissable?: boolean;
+ dismissableBackButton?: boolean;
+ footer?: React.ReactNode;
+ title?: React.ReactNode;
+};
+
+jest.mock('@strings/translations', () => ({
+ getString: (key: string) => key,
+}));
+
+jest.mock('@components/Button/Button', () => {
+ const ReactModule = jest.requireActual('react');
+ const { Text, View } = jest.requireActual('react-native');
+
+ return {
+ __esModule: true,
+ default: ({ children, disabled, loading, onPress }: MockButtonProps) => {
+ if (onPress) mockButtonPresses.set(String(children), onPress);
+ return ReactModule.createElement(
+ View,
+ {
+ accessibilityState: { busy: loading, disabled },
+ testID: String(children),
+ },
+ ReactModule.createElement(Text, null, children),
+ );
+ },
+ };
+});
+
+jest.mock('../KeyboardAwareModal', () => {
+ const ReactModule = jest.requireActual('react');
+ const { Text, View } = jest.requireActual('react-native');
+
+ return {
+ __esModule: true,
+ default: ({
+ children,
+ dismissable,
+ dismissableBackButton,
+ footer,
+ title,
+ }: MockKeyboardAwareModalProps) =>
+ ReactModule.createElement(
+ View,
+ {
+ accessibilityState: { disabled: !dismissable },
+ accessibilityValue: { text: String(dismissableBackButton) },
+ testID: 'keyboard-aware-modal',
+ },
+ ReactModule.createElement(Text, null, title),
+ children,
+ footer,
+ ),
+ };
+});
+
+const confirmButton = () => screen.getByTestId('common.save');
+const pressButton = (label: string) => mockButtonPresses.get(label)?.();
+
+describe('KeyboardAvoidingModal', () => {
+ beforeEach(() => {
+ mockButtonPresses.clear();
+ });
+
+ it('dismisses exactly once after a successful confirm', async () => {
+ const onConfirm = jest.fn(() => true);
+ const onDismiss = jest.fn();
+
+ render(
+ ,
+ );
+
+ await act(async () => {
+ await pressButton('common.save');
+ });
+
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps the modal open when confirm returns false', async () => {
+ const onDismiss = jest.fn();
+
+ render(
+ false}
+ onDismiss={onDismiss}
+ />,
+ );
+
+ await act(async () => {
+ await pressButton('common.save');
+ });
+
+ expect(onDismiss).not.toHaveBeenCalled();
+ expect(confirmButton().props.accessibilityState).toMatchObject({
+ busy: false,
+ disabled: false,
+ });
+ });
+
+ it('locks every dismiss action and prevents duplicate async confirms', async () => {
+ let resolveConfirm: (() => void) | undefined;
+ const onConfirm = jest.fn(
+ () =>
+ new Promise(resolve => {
+ resolveConfirm = resolve;
+ }),
+ );
+ const onDismiss = jest.fn();
+
+ render(
+ ,
+ );
+
+ let firstConfirm: Promise | undefined;
+ await act(async () => {
+ firstConfirm = pressButton('common.save') as Promise;
+ pressButton('common.save');
+ });
+
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ expect(confirmButton().props.accessibilityState).toMatchObject({
+ busy: true,
+ disabled: true,
+ });
+ expect(
+ screen.getByTestId('common.cancel').props.accessibilityState.disabled,
+ ).toBe(true);
+ expect(
+ screen.getByTestId('common.reset').props.accessibilityState.disabled,
+ ).toBe(true);
+ expect(
+ screen.getByTestId('keyboard-aware-modal').props.accessibilityState
+ .disabled,
+ ).toBe(true);
+ expect(
+ screen.getByTestId('keyboard-aware-modal').props.accessibilityValue.text,
+ ).toBe('false');
+
+ await act(async () => {
+ resolveConfirm?.();
+ await firstConfirm;
+ });
+
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ });
+
+ it('restores its loading state and propagates confirm errors', async () => {
+ const error = new Error('confirm failed');
+ const onDismiss = jest.fn();
+
+ render(
+ Promise.reject(error)}
+ onDismiss={onDismiss}
+ />,
+ );
+
+ let caughtError: unknown;
+ await act(async () => {
+ try {
+ await pressButton('common.save');
+ } catch (caught) {
+ caughtError = caught;
+ }
+ });
+
+ expect(caughtError).toBe(error);
+ expect(onDismiss).not.toHaveBeenCalled();
+ expect(confirmButton().props.accessibilityState).toMatchObject({
+ busy: false,
+ disabled: false,
+ });
+ });
+
+ it('runs reset without dismissing and cancel before one dismiss', () => {
+ const onReset = jest.fn();
+ const onCancel = jest.fn();
+ const onDismiss = jest.fn();
+
+ render(
+ ,
+ );
+
+ act(() => pressButton('common.reset'));
+ expect(onReset).toHaveBeenCalledTimes(1);
+ expect(onDismiss).not.toHaveBeenCalled();
+
+ act(() => pressButton('common.cancel'));
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/components/Modal/__tests__/KeyboardAwareModal.test.tsx b/src/components/Modal/__tests__/KeyboardAwareModal.test.tsx
new file mode 100644
index 0000000000..e038708772
--- /dev/null
+++ b/src/components/Modal/__tests__/KeyboardAwareModal.test.tsx
@@ -0,0 +1,122 @@
+import { fireEvent, render, screen } from '@testing-library/react-native';
+import React from 'react';
+import { Keyboard, StyleProp, StyleSheet, Text, ViewStyle } from 'react-native';
+
+import KeyboardAwareModal from '../KeyboardAwareModal';
+
+type MockModalProps = {
+ children?: React.ReactNode;
+ onDismiss: () => void;
+ style?: StyleProp;
+};
+
+type MockPortalProps = {
+ children?: React.ReactNode;
+};
+
+jest.mock('@hooks/persisted', () => ({
+ useTheme: () => ({ surface: '#ffffff', onSurface: '#111111' }),
+}));
+
+jest.mock('react-native-keyboard-controller', () => ({
+ useAnimatedKeyboard: () => ({
+ height: { value: 0 },
+ state: { value: 0 },
+ }),
+}));
+
+jest.mock('react-native-safe-area-context', () => ({
+ useSafeAreaInsets: () => ({ top: 24, right: 0, bottom: 16, left: 0 }),
+}));
+
+jest.mock('react-native-paper', () => {
+ const ReactModule = jest.requireActual('react');
+ const { Pressable, View } = jest.requireActual('react-native');
+
+ return {
+ Modal: ({ children, onDismiss, style }: MockModalProps) =>
+ ReactModule.createElement(
+ View,
+ { testID: 'paper-modal', style },
+ children,
+ ReactModule.createElement(Pressable, {
+ testID: 'dismiss-modal',
+ onPress: onDismiss,
+ }),
+ ),
+ Portal: ({ children }: MockPortalProps) => children,
+ overlay: (_level: number, color: string) => color,
+ };
+});
+
+describe('KeyboardAwareModal', () => {
+ beforeEach(() => {
+ jest.spyOn(Keyboard, 'dismiss').mockImplementation(() => undefined);
+ });
+
+ it('renders its regions, merges wrapper styles and centralizes dismiss', () => {
+ const onDismiss = jest.fn();
+
+ render(
+ Modal footer}
+ onDismiss={onDismiss}
+ style={styles.transparent}
+ >
+ Modal body
+ ,
+ );
+
+ expect(screen.getByText('Modal title')).toBeTruthy();
+ expect(screen.getByText('Modal body')).toBeTruthy();
+ expect(screen.getByText('Modal footer')).toBeTruthy();
+ expect(
+ StyleSheet.flatten(screen.getByTestId('paper-modal').props.style),
+ ).toMatchObject({
+ justifyContent: 'center',
+ opacity: 0.5,
+ paddingHorizontal: 24,
+ });
+
+ fireEvent.press(screen.getByTestId('dismiss-modal'));
+
+ expect(Keyboard.dismiss).toHaveBeenCalledTimes(1);
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ });
+
+ it('supports scrollable and non-scrollable content', () => {
+ const view = render(
+
+ Scrollable body
+ ,
+ );
+
+ expect(screen.getByTestId('modal-scroll-view')).toBeTruthy();
+
+ view.rerender(
+
+ Static body
+ ,
+ );
+
+ expect(screen.queryByTestId('modal-scroll-view')).toBeNull();
+ expect(screen.getByText('Static body')).toBeTruthy();
+ });
+});
+
+const styles = StyleSheet.create({
+ transparent: {
+ opacity: 0.5,
+ },
+});
diff --git a/src/components/Modal/useKeyboardModalDismiss.ts b/src/components/Modal/useKeyboardModalDismiss.ts
new file mode 100644
index 0000000000..d2edf9e2a6
--- /dev/null
+++ b/src/components/Modal/useKeyboardModalDismiss.ts
@@ -0,0 +1,10 @@
+import { useCallback } from 'react';
+import { Keyboard } from 'react-native';
+
+const useKeyboardModalDismiss = (onDismiss: () => void) =>
+ useCallback(() => {
+ Keyboard.dismiss();
+ onDismiss();
+ }, [onDismiss]);
+
+export default useKeyboardModalDismiss;
diff --git a/src/components/TextInput/index.tsx b/src/components/TextInput/index.tsx
deleted file mode 100644
index 1cfe158ad3..0000000000
--- a/src/components/TextInput/index.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import { useTheme } from '@hooks/persisted';
-import React, { useState } from 'react';
-import { StyleSheet, TextInputProps as RNTextInputProps } from 'react-native';
-import { TextInput as RNTextInput } from 'react-native-gesture-handler';
-
-interface TextInputProps extends RNTextInputProps {
- error?: boolean;
- value?: never;
- forceFocused?: boolean;
-}
-
-const TextInput = ({
- onBlur,
- onFocus,
- error,
- forceFocused,
- style,
- ...props
-}: TextInputProps) => {
- const theme = useTheme();
-
- const [inputFocused, setInputFocused] = useState(false);
-
- const _onFocus: RNTextInputProps['onFocus'] = e => {
- setInputFocused(true);
- onFocus?.(e);
- };
- const _onBlur: RNTextInputProps['onBlur'] = e => {
- setInputFocused(false);
- onBlur?.(e);
- };
-
- const isFocused = forceFocused ?? inputFocused;
- const borderWidth = isFocused || error ? 2 : 1;
- const margin = isFocused || error ? 0 : 1;
- return (
-
- );
-};
-
-export default TextInput;
-
-const styles = StyleSheet.create({
- textInput: {
- borderRadius: 4,
- borderStyle: 'solid',
- fontSize: 16,
- paddingHorizontal: 16,
- paddingVertical: 10,
- },
-});
diff --git a/src/components/index.ts b/src/components/index.ts
index 43624978ba..5fcda1061a 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -17,6 +17,10 @@ export { default as LoadingScreenV2 } from './LoadingScreenV2/LoadingScreenV2';
export * from './LogViewer';
export { default as MarkdownText } from './MarkdownText/MarkdownText';
export { default as Menu } from './Menu';
+export type { KeyboardAvoidingModalProps } from './Modal/KeyboardAvoidingModal';
+export { default as KeyboardAvoidingModal } from './Modal/KeyboardAvoidingModal';
+export type { KeyboardAwareModalProps } from './Modal/KeyboardAwareModal';
+export { default as KeyboardAwareModal } from './Modal/KeyboardAwareModal';
export { default as Modal } from './Modal/Modal';
export { RadioButton } from './RadioButton/RadioButton';
export { default as SafeAreaView } from './SafeAreaView/SafeAreaView';
@@ -24,4 +28,3 @@ export { default as SearchbarV2 } from './SearchbarV2/SearchbarV2';
export { SegmentedControl } from './SegmentedControl';
export { default as SwitchItem } from './Switch/SwitchItem';
export { default as TaskLogDialog } from './TaskLogDialog';
-export { default as TextInput } from './TextInput';
diff --git a/src/hooks/common/useKeyboardHeight.ts b/src/hooks/common/useKeyboardHeight.ts
deleted file mode 100644
index 3af2afc9c4..0000000000
--- a/src/hooks/common/useKeyboardHeight.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { useEffect, useState } from 'react';
-import { Keyboard, KeyboardEvent } from 'react-native';
-
-export const useKeyboardHeight = () => {
- const [keyboardHeight, setKeyboardHeight] = useState(0);
-
- useEffect(() => {
- function onKeyboardDidShow(e: KeyboardEvent) {
- setKeyboardHeight(e.endCoordinates.height);
- }
-
- function onKeyboardDidHide() {
- setKeyboardHeight(0);
- }
-
- const showSubscription = Keyboard.addListener(
- 'keyboardDidShow',
- onKeyboardDidShow,
- );
- const hideSubscription = Keyboard.addListener(
- 'keyboardDidHide',
- onKeyboardDidHide,
- );
- return () => {
- showSubscription.remove();
- hideSubscription.remove();
- };
- }, []);
-
- return keyboardHeight;
-};
diff --git a/src/screens/Categories/components/AddCategoryModal.tsx b/src/screens/Categories/components/AddCategoryModal.tsx
index 2cac2abb28..00d0f1c7e5 100644
--- a/src/screens/Categories/components/AddCategoryModal.tsx
+++ b/src/screens/Categories/components/AddCategoryModal.tsx
@@ -1,11 +1,9 @@
-import { Button, Modal } from '@components/index';
+import { KeyboardAvoidingModal } from '@components';
import { useTheme } from '@hooks/persisted';
import { getString } from '@strings/translations';
import { showToast } from '@utils/showToast';
import React, { useState } from 'react';
-import { StyleSheet, Text, View } from 'react-native';
-import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
-import { Portal, TextInput } from 'react-native-paper';
+import { TextInput } from 'react-native-paper';
import {
createCategory,
@@ -36,64 +34,39 @@ const AddCategoryModal: React.FC = ({
setCategoryName('');
closeModal();
}
- function finalize() {
- onSuccess();
- close();
- }
-
return (
-
-
-
-
- {getString(
- isEditMode
- ? 'categories.editCategories'
- : 'categories.addCategories',
- )}
-
-
-
-
-
-
-
+ {
+ if (isCategoryNameDuplicate(categoryName)) {
+ showToast(getString('categories.duplicateError'));
+ return false;
+ }
+
+ if (isEditMode && category) {
+ await updateCategory(category.id, categoryName);
+ } else {
+ await createCategory(categoryName);
+ }
+ onSuccess();
+ }}
+ >
+
+
);
};
export default AddCategoryModal;
-
-const styles = StyleSheet.create({
- btnContainer: {
- flexDirection: 'row-reverse',
- marginTop: 24,
- },
- modalTitle: {
- fontSize: 24,
- marginBottom: 16,
- },
-});
diff --git a/src/screens/Categories/components/__tests__/AddCategoryModal.test.tsx b/src/screens/Categories/components/__tests__/AddCategoryModal.test.tsx
new file mode 100644
index 0000000000..43e6d0503f
--- /dev/null
+++ b/src/screens/Categories/components/__tests__/AddCategoryModal.test.tsx
@@ -0,0 +1,186 @@
+import {
+ createCategory,
+ isCategoryNameDuplicate,
+ updateCategory,
+} from '@database/queries/CategoryQueries';
+import { Category } from '@database/types';
+import { act, fireEvent, render, screen } from '@testing-library/react-native';
+import { showToast } from '@utils/showToast';
+import React from 'react';
+import { TextInputProps } from 'react-native';
+
+import AddCategoryModal from '../AddCategoryModal';
+
+let mockConfirmCategory: (() => Promise) | undefined;
+
+type MockKeyboardAvoidingModalProps = {
+ children?: React.ReactNode;
+ confirmLabel: string;
+ onConfirm: () => boolean | void | Promise;
+ onDismiss: () => void;
+ title: React.ReactNode;
+};
+
+type MockTextInputProps = Pick<
+ TextInputProps,
+ 'defaultValue' | 'onChangeText' | 'placeholder'
+>;
+
+jest.mock('@components', () => {
+ const ReactModule = jest.requireActual('react');
+ const { Text, View } = jest.requireActual('react-native');
+
+ return {
+ KeyboardAvoidingModal: ({
+ children,
+ confirmLabel,
+ onConfirm,
+ onDismiss,
+ title,
+ }: MockKeyboardAvoidingModalProps) => {
+ mockConfirmCategory = async () => {
+ const result = await onConfirm();
+ if (result !== false) onDismiss();
+ };
+ return ReactModule.createElement(
+ View,
+ null,
+ ReactModule.createElement(Text, null, title),
+ children,
+ ReactModule.createElement(
+ View,
+ { testID: 'confirm-category' },
+ ReactModule.createElement(Text, null, confirmLabel),
+ ),
+ );
+ },
+ };
+});
+
+jest.mock('@hooks/persisted', () => ({
+ useTheme: () => ({ outline: '#777777' }),
+}));
+
+jest.mock('@strings/translations', () => ({
+ getString: (key: string) => key,
+}));
+
+jest.mock('@utils/showToast', () => ({
+ showToast: jest.fn(),
+}));
+
+jest.mock('../../../../database/queries/CategoryQueries', () => ({
+ createCategory: jest.fn(),
+ isCategoryNameDuplicate: jest.fn(),
+ updateCategory: jest.fn(),
+}));
+
+jest.mock('react-native-paper', () => {
+ const ReactModule = jest.requireActual('react');
+ const { TextInput } = jest.requireActual('react-native');
+
+ return {
+ TextInput: (props: MockTextInputProps) =>
+ ReactModule.createElement(TextInput, {
+ ...props,
+ testID: 'category-name-input',
+ }),
+ };
+});
+
+const mockCreateCategory = createCategory as jest.MockedFunction<
+ typeof createCategory
+>;
+const mockIsCategoryNameDuplicate =
+ isCategoryNameDuplicate as jest.MockedFunction<
+ typeof isCategoryNameDuplicate
+ >;
+const mockUpdateCategory = updateCategory as jest.MockedFunction<
+ typeof updateCategory
+>;
+const mockShowToast = showToast as jest.MockedFunction;
+
+const submit = async () => {
+ await act(async () => {
+ await mockConfirmCategory?.();
+ });
+};
+
+describe('AddCategoryModal', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockConfirmCategory = undefined;
+ mockIsCategoryNameDuplicate.mockReturnValue(false);
+ mockCreateCategory.mockResolvedValue({ id: 1, name: 'New', sort: 1 });
+ mockUpdateCategory.mockResolvedValue(undefined);
+ });
+
+ it('keeps the modal open and skips writes for duplicate names', async () => {
+ mockIsCategoryNameDuplicate.mockReturnValue(true);
+ const closeModal = jest.fn();
+ const onSuccess = jest.fn();
+
+ render(
+ ,
+ );
+ fireEvent.changeText(
+ screen.getByTestId('category-name-input'),
+ 'Duplicate',
+ );
+ await submit();
+
+ expect(mockIsCategoryNameDuplicate).toHaveBeenCalledWith('Duplicate');
+ expect(mockShowToast).toHaveBeenCalledWith('categories.duplicateError');
+ expect(mockCreateCategory).not.toHaveBeenCalled();
+ expect(mockUpdateCategory).not.toHaveBeenCalled();
+ expect(onSuccess).not.toHaveBeenCalled();
+ expect(closeModal).not.toHaveBeenCalled();
+ });
+
+ it('creates a category before notifying and dismissing', async () => {
+ const closeModal = jest.fn();
+ const onSuccess = jest.fn();
+
+ render(
+ ,
+ );
+ fireEvent.changeText(screen.getByTestId('category-name-input'), 'New');
+ await submit();
+
+ expect(mockCreateCategory).toHaveBeenCalledWith('New');
+ expect(mockUpdateCategory).not.toHaveBeenCalled();
+ expect(onSuccess).toHaveBeenCalledTimes(1);
+ expect(closeModal).toHaveBeenCalledTimes(1);
+ });
+
+ it('updates the selected category before notifying and dismissing', async () => {
+ const closeModal = jest.fn();
+ const onSuccess = jest.fn();
+ const category = { id: 7, name: 'Old' } as Category;
+
+ render(
+ ,
+ );
+ fireEvent.changeText(screen.getByTestId('category-name-input'), 'Updated');
+ await submit();
+
+ expect(mockUpdateCategory).toHaveBeenCalledWith(7, 'Updated');
+ expect(mockCreateCategory).not.toHaveBeenCalled();
+ expect(onSuccess).toHaveBeenCalledTimes(1);
+ expect(closeModal).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/screens/novel/components/EditInfoModal.tsx b/src/screens/novel/components/EditInfoModal.tsx
index cd15454798..3474041507 100644
--- a/src/screens/novel/components/EditInfoModal.tsx
+++ b/src/screens/novel/components/EditInfoModal.tsx
@@ -1,4 +1,4 @@
-import KeyboardAvoidingModal from '@components/Modal/KeyboardAvoidingModal';
+import { KeyboardAvoidingModal } from '@components';
import { updateNovelInfo } from '@database/queries/NovelQueries';
import { NovelInfo } from '@database/types';
import { NovelStatus } from '@plugins/types';
@@ -15,7 +15,7 @@ import {
Text,
View,
} from 'react-native';
-import { Portal, TextInput } from 'react-native-paper';
+import { TextInput } from 'react-native-paper';
interface EditInfoModalProps {
theme: ThemeColors;
@@ -66,154 +66,145 @@ const EditInfoModal = ({
const status = Object.values(NovelStatus);
return (
-
- {
- setNovel(novelInfo);
- updateNovelInfo(novelInfo);
- hideModal();
- return true;
- }}
- onCancel={hideModal}
- onReset={() => {
- setNovelInfo(initialNovelInfo);
- updateNovelInfo(initialNovelInfo);
- }}
- >
-
-
- {getString('novelScreen.edit.status')}
-
-
- {status.map((item, index) => (
-
-
- setNovelInfo(prev => ({ ...prev, status: item }))
- }
+ {
+ setNovel(novelInfo);
+ updateNovelInfo(novelInfo);
+ }}
+ onReset={() => {
+ setNovelInfo(initialNovelInfo);
+ updateNovelInfo(initialNovelInfo);
+ }}
+ >
+
+
+ {getString('novelScreen.edit.status')}
+
+
+ {status.map((item, index) => (
+
+
+ setNovelInfo(prev => ({ ...prev, status: item }))
+ }
+ >
+
-
- {translateNovelStatus(item)}
-
-
-
- ))}
-
-
- setNovelInfo(prev => ({ ...prev, name: text }))}
- dense
- style={styles.inputWrapper}
- />
-
- setNovelInfo(prev => ({ ...prev, author: text }))
- }
- dense
- style={styles.inputWrapper}
- />
-
- setNovelInfo(prev => ({ ...prev, artist: text }))
- }
- dense
- style={styles.inputWrapper}
- />
-
- setNovelInfo(prev => ({ ...prev, summary: text }))
- }
- theme={{ colors: { ...theme } }}
- dense
- style={styles.inputWrapper}
- />
+ {translateNovelStatus(item)}
+
+
+
+ ))}
+
+
+ setNovelInfo(prev => ({ ...prev, name: text }))}
+ dense
+ style={styles.inputWrapper}
+ />
+ setNovelInfo(prev => ({ ...prev, author: text }))}
+ dense
+ style={styles.inputWrapper}
+ />
+ setNovelInfo(prev => ({ ...prev, artist: text }))}
+ dense
+ style={styles.inputWrapper}
+ />
+
+ setNovelInfo(prev => ({ ...prev, summary: text }))
+ }
+ theme={{ colors: { ...theme } }}
+ dense
+ style={styles.inputWrapper}
+ />
+
+ setNewGenre(text)}
+ onSubmitEditing={() => {
+ const newGenreTrimmed = newGenre.trim();
- setNewGenre(text)}
- onSubmitEditing={() => {
- const newGenreTrimmed = newGenre.trim();
+ if (newGenreTrimmed === '') {
+ return;
+ }
- if (newGenreTrimmed === '') {
- return;
- }
+ setNovelInfo(prevVal => ({
+ ...prevVal,
+ genres: novelInfo.genres
+ ? `${novelInfo.genres},` + newGenreTrimmed
+ : newGenreTrimmed,
+ }));
+ setNewGenre('');
+ setGenreKey(prev => prev + 1);
+ }}
+ theme={{ colors: { ...theme } }}
+ dense
+ style={styles.inputWrapper}
+ />
- setNovelInfo(prevVal => ({
- ...prevVal,
- genres: novelInfo.genres
- ? `${novelInfo.genres},` + newGenreTrimmed
- : newGenreTrimmed,
- }));
- setNewGenre('');
- setGenreKey(prev => prev + 1);
- }}
- theme={{ colors: { ...theme } }}
- dense
- style={styles.inputWrapper}
+ {novelInfo.genres !== undefined && novelInfo.genres !== '' ? (
+ 'novelTag' + index}
+ renderItem={({ item }) => (
+ removeTag(item)}>
+ {item}
+
+ )}
+ showsHorizontalScrollIndicator={false}
/>
-
- {novelInfo.genres !== undefined && novelInfo.genres !== '' ? (
- 'novelTag' + index}
- renderItem={({ item }) => (
- removeTag(item)}>
- {item}
-
- )}
- showsHorizontalScrollIndicator={false}
- />
- ) : null}
-
-
+ ) : null}
+
);
};
diff --git a/src/screens/novel/components/Tracker/SetTrackChaptersDialog.tsx b/src/screens/novel/components/Tracker/SetTrackChaptersDialog.tsx
index 79434c5202..2a4cefbdb8 100644
--- a/src/screens/novel/components/Tracker/SetTrackChaptersDialog.tsx
+++ b/src/screens/novel/components/Tracker/SetTrackChaptersDialog.tsx
@@ -1,9 +1,6 @@
-import { Button, DialogTitle, Modal } from '@components';
+import { KeyboardAvoidingModal } from '@components';
import { useTheme } from '@hooks/persisted';
-import { getString } from '@strings/translations';
import React, { useEffect, useState } from 'react';
-import { StyleSheet, View } from 'react-native';
-import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
import { TextInput } from 'react-native-paper';
import { TrackChaptersDialogProps } from './types';
@@ -32,40 +29,29 @@ const SetTrackChaptersDialog: React.FC = ({
};
return (
-
-
-
-
-
- {getString('common.cancel')}
- {getString('common.save')}
-
-
-
+
+
+
);
};
export default SetTrackChaptersDialog;
-
-const styles = StyleSheet.create({
- buttonContainer: {
- flexDirection: 'row',
- justifyContent: 'flex-end',
- gap: 8,
- marginTop: 16,
- },
-});
diff --git a/src/screens/novel/components/Tracker/SetTrackScoreDialog.tsx b/src/screens/novel/components/Tracker/SetTrackScoreDialog.tsx
index b2c874292b..c64a9ad3b1 100644
--- a/src/screens/novel/components/Tracker/SetTrackScoreDialog.tsx
+++ b/src/screens/novel/components/Tracker/SetTrackScoreDialog.tsx
@@ -1,8 +1,5 @@
-import { Button, DialogTitle, Modal } from '@components';
-import { getString } from '@strings/translations';
+import { KeyboardAvoidingModal } from '@components';
import React, { useEffect, useMemo, useState } from 'react';
-import { StyleSheet, View } from 'react-native';
-import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
import {
AniListScoreSelector,
@@ -29,7 +26,6 @@ const SetTrackScoreDialog: React.FC = ({
const handleSave = () => {
onUpdateScore(selectedScore);
- onDismiss();
};
const ScoreSelector = useMemo(() => {
@@ -68,26 +64,15 @@ const SetTrackScoreDialog: React.FC = ({
}, [tracker, trackItem, selectedScore]);
return (
-
-
-
- {ScoreSelector}
-
- {getString('common.cancel')}
- {getString('common.save')}
-
-
-
+
+ {ScoreSelector}
+
);
};
export default SetTrackScoreDialog;
-
-const styles = StyleSheet.create({
- buttonContainer: {
- flexDirection: 'row',
- justifyContent: 'flex-end',
- gap: 8,
- marginTop: 16,
- },
-});
diff --git a/src/screens/settings/SettingsRepositoryScreen/components/AddRepositoryModal.tsx b/src/screens/settings/SettingsRepositoryScreen/components/AddRepositoryModal.tsx
index eb919f9ed2..92c77466ef 100644
--- a/src/screens/settings/SettingsRepositoryScreen/components/AddRepositoryModal.tsx
+++ b/src/screens/settings/SettingsRepositoryScreen/components/AddRepositoryModal.tsx
@@ -1,11 +1,9 @@
-import { Button, Modal } from '@components/index';
+import { KeyboardAvoidingModal } from '@components';
import { Repository } from '@database/types';
import { useTheme } from '@hooks/persisted';
import { getString } from '@strings/translations';
import React, { useState } from 'react';
-import { StyleSheet, Text, View } from 'react-native';
-import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
-import { Portal, TextInput } from 'react-native-paper';
+import { TextInput } from 'react-native-paper';
interface AddRepositoryModalProps {
repository?: Repository;
@@ -24,46 +22,24 @@ const AddRepositoryModal: React.FC = ({
const [repositoryUrl, setRepositoryUrl] = useState(repository?.url || '');
return (
-
-
-
-
- {repository ? 'Edit repository' : 'Add repository'}
-
-
-
- {
- upsertRepository(repositoryUrl, repository);
- closeModal();
- }}
- />
-
-
-
-
-
+ upsertRepository(repositoryUrl, repository)}
+ >
+
+
);
};
export default AddRepositoryModal;
-
-const styles = StyleSheet.create({
- btnContainer: {
- flexDirection: 'row-reverse',
- marginTop: 24,
- },
- modalTitle: {
- fontSize: 24,
- marginBottom: 16,
- },
-});
diff --git a/src/screens/settings/components/ConnectionModal.tsx b/src/screens/settings/components/ConnectionModal.tsx
index 7c7478d5b6..1bab2aa7fd 100644
--- a/src/screens/settings/components/ConnectionModal.tsx
+++ b/src/screens/settings/components/ConnectionModal.tsx
@@ -1,9 +1,7 @@
-import { Button, Modal } from '@components';
+import { KeyboardAvoidingModal } from '@components';
import { getString } from '@strings/translations';
import { ThemeColors } from '@theme/types';
import React from 'react';
-import { StyleSheet, Text, View } from 'react-native';
-import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
import { TextInput } from 'react-native-paper';
interface ConnectionModalProps {
@@ -30,52 +28,32 @@ const ConnectionModal: React.FC = ({
setPort,
}) => {
return (
-
-
-
- {title}
-
-
-
-
- {
- closeModal();
- handle(ipv4, port);
- }}
- />
-
-
-
-
+ handle(ipv4, port)}
+ >
+
+
+
);
};
export default ConnectionModal;
-
-const styles = StyleSheet.create({
- btnContainer: {
- flexDirection: 'row-reverse',
- marginTop: 24,
- },
- modalTitle: {
- fontSize: 24,
- marginBottom: 16,
- },
-});
From 37ebca48bec913a72b58f2746f95f210d70194e2 Mon Sep 17 00:00:00 2001
From: Elysia <71698422+aiko-chan-ai@users.noreply.github.com>
Date: Fri, 17 Jul 2026 21:51:45 +0700
Subject: [PATCH 4/7] refactor
---
.../ColorPickerModal/ColorPickerModal.tsx | 118 ++--
src/components/Modal/KeyboardAwareModal.tsx | 1 -
src/components/SearchbarV2/SearchbarV2.tsx | 3 +-
src/components/TextInput/index.tsx | 58 ++
src/components/index.ts | 1 +
.../BrowseSourceScreen/BrowseSourceScreen.tsx | 2 +-
.../components/FilterBottomSheet.tsx | 11 +-
src/screens/Categories/CategoriesScreen.tsx | 2 +-
.../components/AddCategoryModal.tsx | 17 +-
.../Categories/components/CategoryCard.tsx | 14 +-
.../components/DeleteCategoryModal.tsx | 2 +-
.../GlobalSearchScreen/GlobalSearchScreen.tsx | 2 +-
.../browse/components/InstalledTab.tsx | 19 +-
.../components/Modals/SourceSettings.tsx | 306 +++++-----
src/screens/library/LibraryScreen.tsx | 2 +-
.../library/components/LibraryListView.tsx | 2 +-
src/screens/novel/NovelScreen.tsx | 66 ++-
.../components/DownloadCustomChapterModal.tsx | 113 ++--
.../novel/components/EditInfoModal.tsx | 266 ++++-----
.../novel/components/ExportEpubModal.tsx | 244 ++++----
.../components/ExportNovelAsEpubButton.tsx | 41 +-
.../novel/components/JumpToChapterModal.tsx | 144 ++---
.../novel/components/SetCategoriesModal.tsx | 2 +-
.../components/Tracker/ScoreSelectors.tsx | 6 +-
.../Tracker/SetTrackChaptersDialog.tsx | 7 +-
.../Tracker/SetTrackStatusDialog.tsx | 28 +-
.../components/Tracker/TrackSearchDialog.tsx | 104 ++--
.../novel/components/Tracker/TrackSheet.tsx | 74 ++-
.../__tests__/ExportEpubModal.test.tsx | 50 +-
.../reader/components/ChapterDrawer/index.tsx | 2 +-
.../ReaderBottomSheet/ReaderFontPicker.tsx | 2 +-
.../components/ReaderBottomSheet/TTSTab.tsx | 2 +-
.../ReaderBottomSheet/TranslateTab.tsx | 2 +-
.../reader/components/ReaderSearchbar.tsx | 6 +-
.../TranslatePromptScreen.tsx | 2 +-
.../components/AIProviderModal.tsx | 529 ++++++++----------
.../components/PromptEditModal.tsx | 147 ++---
.../settings/SettingsAIScreen/index.tsx | 13 +-
.../settings/SettingsAdvancedScreen.tsx | 205 +++----
.../Components/GoogleDriveModal.tsx | 78 ++-
.../Components/SelfHostModal.tsx | 41 +-
.../modals/DownloadCooldownModal.tsx | 112 ++--
.../Modals/VoicePickerModal.tsx | 119 ++--
.../SettingsReaderScreen/ReaderTextSize.tsx | 2 +-
.../SettingsReaderScreen.tsx | 2 +-
.../tabs/AccessibilityTab.tsx | 2 +-
.../SettingsReaderScreen/tabs/AdvancedTab.tsx | 8 +-
.../SettingsReaderScreen/tabs/DisplayTab.tsx | 2 +-
.../tabs/NavigationTab.tsx | 15 +-
.../SettingsReaderScreen/tabs/ThemeTab.tsx | 45 +-
.../SettingsRepositoryScreen.tsx | 14 +-
.../components/AddRepositoryModal.tsx | 7 +-
.../components/DeleteRepositoryModal.tsx | 2 +-
.../settings/SettingsTrackerScreen.tsx | 19 +-
.../settings/components/ConnectionModal.tsx | 11 +-
.../components/TrackerLoginDialog.tsx | 149 ++---
strings/languages/en/strings.json | 1 +
strings/languages/vi_VN/strings.json | 1 +
strings/types/index.ts | 1 +
59 files changed, 1481 insertions(+), 1765 deletions(-)
create mode 100644 src/components/TextInput/index.tsx
diff --git a/src/components/ColorPickerModal/ColorPickerModal.tsx b/src/components/ColorPickerModal/ColorPickerModal.tsx
index c2881854e0..aafbac1fe8 100644
--- a/src/components/ColorPickerModal/ColorPickerModal.tsx
+++ b/src/components/ColorPickerModal/ColorPickerModal.tsx
@@ -1,10 +1,6 @@
-import { Button, Modal } from '@components';
-import { Row } from '@components/Common';
-import { getString } from '@strings/translations';
+import { KeyboardAvoidingModal, StableTextInput } from '@components';
import React, { useState } from 'react';
-import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
-import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
-import { Portal, TextInput } from 'react-native-paper';
+import { FlatList, Pressable,StyleSheet, Text, View } from 'react-native';
import { ThemeColors } from '../../theme/types';
@@ -32,27 +28,27 @@ const ColorPickerModal: React.FC = ({
const onDismiss = () => {
closeModal();
- if (error) {
- setText(color);
- }
+ setText(color);
setError(null);
};
const onChangeText = (txt: string) => setText(txt);
- const onSubmitEditing = () => {
+ const handleConfirm = () => {
const re = /^#([0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3})$/i;
if (text?.match(re)) {
onSubmit(text);
- closeModal();
} else {
setError('Enter a valid hex color code');
+ return false;
}
};
+
const onReset = () => {
onSubmit(undefined);
- closeModal();
+ setText(color);
+ setError(null);
};
const accentColors = [
@@ -79,56 +75,47 @@ const ColorPickerModal: React.FC = ({
];
return (
-
-
-
-
- {title}
-
- {showAccentColors ? (
- item}
- renderItem={({ item }) => (
-
- {
- onSubmit(item);
- closeModal();
- }}
- />
-
- )}
- />
- ) : null}
-
- {error}
-
-
-
-
-
-
-
+
+ {showAccentColors ? (
+ item}
+ renderItem={({ item }) => (
+
+ {
+ setText(item);
+ setError(null);
+ }}
+ />
+
+ )}
+ />
+ ) : null}
+
+ {error}
+
);
};
@@ -139,10 +126,6 @@ const styles = StyleSheet.create({
color: '#FF0033',
paddingTop: 8,
},
- modalTitle: {
- fontSize: 24,
- marginBottom: 16,
- },
item: {
borderRadius: 4,
overflow: 'hidden',
@@ -153,7 +136,4 @@ const styles = StyleSheet.create({
},
flex: { flex: 1 },
marginBottom: { marginBottom: 8 },
- row: {
- justifyContent: 'flex-end',
- },
});
diff --git a/src/components/Modal/KeyboardAwareModal.tsx b/src/components/Modal/KeyboardAwareModal.tsx
index f877d64b49..81c21f92a2 100644
--- a/src/components/Modal/KeyboardAwareModal.tsx
+++ b/src/components/Modal/KeyboardAwareModal.tsx
@@ -149,7 +149,6 @@ const styles = StyleSheet.create({
},
modalTitle: {
fontSize: 24,
- lineHeight: 24,
},
body: {
flexShrink: 1,
diff --git a/src/components/SearchbarV2/SearchbarV2.tsx b/src/components/SearchbarV2/SearchbarV2.tsx
index eb60d23027..06c130fff5 100644
--- a/src/components/SearchbarV2/SearchbarV2.tsx
+++ b/src/components/SearchbarV2/SearchbarV2.tsx
@@ -1,6 +1,6 @@
import { MaterialDesignIconName } from '@type/icon';
import React, { memo, useRef, useState } from 'react';
-import { Pressable, StyleSheet, TextInput, View } from 'react-native';
+import { Pressable, StyleSheet, TextInput,View } from 'react-native';
import { ThemeColors } from '../../theme/types';
import IconButtonV2 from '../IconButtonV2/IconButtonV2';
@@ -81,7 +81,6 @@ const Searchbar: React.FC = ({
}}
theme={theme}
/>
-
& {
+ defaultValue?: never;
+};
+
+/**
+ * StableTextInput is a custom component built on top of React Native Paper's TextInput,
+ * with a small patch to fix the cursor jumping issue when used as a controlled component (value).
+ */
+const StableTextInput = forwardRef(
+ ({ onSelectionChange, value, ...props }, ref) => {
+ const [selection, setSelection] = useState(null);
+
+ const handleSelectionChange = (
+ event: NativeSyntheticEvent,
+ ) => {
+ setSelection(event.nativeEvent.selection);
+
+ if (onSelectionChange) {
+ onSelectionChange(event);
+ }
+ };
+
+ const textLength = typeof value === 'string' ? value.length : 0;
+ const safeSelection = selection
+ ? {
+ start: Math.min(selection.start, textLength),
+ end: Math.min(selection.end, textLength),
+ }
+ : undefined;
+
+ return (
+
+ );
+ },
+);
+
+StableTextInput.displayName = 'StableTextInput';
+
+export default StableTextInput;
diff --git a/src/components/index.ts b/src/components/index.ts
index 5fcda1061a..0bcaab6348 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -28,3 +28,4 @@ export { default as SearchbarV2 } from './SearchbarV2/SearchbarV2';
export { SegmentedControl } from './SegmentedControl';
export { default as SwitchItem } from './Switch/SwitchItem';
export { default as TaskLogDialog } from './TaskLogDialog';
+export { default as StableTextInput } from './TextInput';
diff --git a/src/screens/BrowseSourceScreen/BrowseSourceScreen.tsx b/src/screens/BrowseSourceScreen/BrowseSourceScreen.tsx
index 690087d8e4..95319a8b8c 100644
--- a/src/screens/BrowseSourceScreen/BrowseSourceScreen.tsx
+++ b/src/screens/BrowseSourceScreen/BrowseSourceScreen.tsx
@@ -1,5 +1,5 @@
+import { ErrorScreenV2, SafeAreaView, SearchbarV2 } from '@components';
import { useLibraryContext } from '@components/Context/LibraryContext';
-import { ErrorScreenV2, SafeAreaView, SearchbarV2 } from '@components/index';
import NovelCover from '@components/NovelCover';
import NovelList from '@components/NovelList';
import SearchHistoryList from '@components/SearchHistoryList/SearchHistoryList';
diff --git a/src/screens/BrowseSourceScreen/components/FilterBottomSheet.tsx b/src/screens/BrowseSourceScreen/components/FilterBottomSheet.tsx
index 22c0fce7c4..82910b5841 100644
--- a/src/screens/BrowseSourceScreen/components/FilterBottomSheet.tsx
+++ b/src/screens/BrowseSourceScreen/components/FilterBottomSheet.tsx
@@ -1,6 +1,5 @@
+import { Button, Checkbox,Menu, StableTextInput } from '@components';
import BottomSheet from '@components/BottomSheet/BottomSheet';
-import { Checkbox } from '@components/Checkbox/Checkbox';
-import { Button, Menu } from '@components/index';
import Switch from '@components/Switch/Switch';
import {
BottomSheetFlatList,
@@ -25,7 +24,7 @@ import {
useWindowDimensions,
View,
} from 'react-native';
-import { overlay, TextInput } from 'react-native-paper';
+import { overlay } from 'react-native-paper';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { getValueFor } from './filterUtils';
@@ -69,7 +68,7 @@ const FilterItem: React.FC = memo(
const value = getValueFor<(typeof filter)['type']>(filter, resolvedValue);
return (
- }
style={[styles.flex, { width: screenWidth - 48 }]}
mode="outlined"
@@ -85,7 +84,7 @@ const FilterItem: React.FC = memo(
{` ${filter.label} `}
}
- defaultValue={value}
+ value={value}
theme={{ colors: { background: 'transparent' } }}
outlineColor={theme.onSurface}
textColor={theme.onSurface}
@@ -115,7 +114,7 @@ const FilterItem: React.FC = memo(
style={[styles.flex, { width: screenWidth - 48 }]}
onPress={toggleCard}
>
- = ({
onSuccess,
}) => {
const theme = useTheme();
- const [categoryName, setCategoryName] = useState(category?.name || '');
+ const defaultCategoryName = isEditMode && category ? category.name : '';
+ const [categoryName, setCategoryName] = useState(defaultCategoryName);
function close() {
- setCategoryName('');
+ // Reset the category name to the default value when closing the modal
+ setCategoryName(defaultCategoryName);
closeModal();
}
return (
@@ -43,6 +44,10 @@ const AddCategoryModal: React.FC = ({
confirmLabel={getString(isEditMode ? 'common.ok' : 'common.add')}
onDismiss={close}
onConfirm={async () => {
+ if (!categoryName.trim()) {
+ showToast(getString('categories.emptyError'));
+ return false;
+ }
if (isCategoryNameDuplicate(categoryName)) {
showToast(getString('categories.duplicateError'));
return false;
@@ -56,9 +61,9 @@ const AddCategoryModal: React.FC = ({
onSuccess();
}}
>
- = ({
+
-
-
-
-
+
>
}
/>
diff --git a/src/screens/browse/components/Modals/SourceSettings.tsx b/src/screens/browse/components/Modals/SourceSettings.tsx
index 7572b365f0..4a36a51911 100644
--- a/src/screens/browse/components/Modals/SourceSettings.tsx
+++ b/src/screens/browse/components/Modals/SourceSettings.tsx
@@ -1,9 +1,15 @@
-import { Button, Checkbox, Menu, Modal, SwitchItem } from '@components/index';
+import {
+ Checkbox,
+ KeyboardAvoidingModal,
+ KeyboardAwareModal,
+ Menu,
+ StableTextInput,
+ SwitchItem,
+} from '@components';
import { useTheme } from '@hooks/persisted';
import { Storage } from '@plugins/helpers/storage';
import { PluginSettings } from '@plugins/types';
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
-import { getString } from '@strings/translations';
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
@@ -12,9 +18,7 @@ import {
Text,
View,
} from 'react-native';
-import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
-import { overlay, TextInput } from 'react-native-paper';
-
+import { overlay } from 'react-native-paper';
interface SourceSettingsModal {
visible: boolean;
onDismiss: () => void;
@@ -89,199 +93,177 @@ const SourceSettingsModal: React.FC = ({
Object.entries(formValues).forEach(([key, value]) => {
storage.set(key, value);
});
- onDismiss();
};
if (isLoading) {
return (
-
+
-
+
);
}
if (!pluginSettings || Object.keys(pluginSettings).length === 0) {
return (
-
-
- {title}
-
+
{description || 'No settings available.'}
-
+
);
}
return (
-
-
-
- {title}
-
- {description}
- {Object.entries(pluginSettings).map(([key, setting]) => {
- if (setting?.type === 'Switch') {
- return (
- handleChange(key, !formValues[key])}
- theme={theme}
- />
- );
- }
- if (setting?.type === 'Select') {
- const selectedOption = setting.options.find(
- opt => opt.value === formValues[key],
- );
- const isMenuOpen = !!openMenus[key];
- return (
-
-
-
- );
- }
- if (setting?.type === 'CheckboxGroup') {
- const value = (formValues[key] || []) as string[];
- const isExpanded = !!openMenus[key];
- return (
-
-
- setOpenMenus(prev => ({ ...prev, [key]: !prev[key] }))
- }
- android_ripple={{ color: theme.rippleColor }}
- >
-
- {setting.label}
-
-
-
- {isExpanded &&
- setting.options.map(option => (
-
- handleChange(
- key,
- insertOrRemoveIntoArray(value, option.value),
- )
- }
- />
- ))}
-
- );
- }
+
+ {description}
+ {Object.entries(pluginSettings).map(([key, setting]) => {
+ if (setting?.type === 'Switch') {
return (
- handleChange(key, value)}
- placeholder={`Enter ${setting.label}`}
- placeholderTextColor={theme.onSurfaceDisabled}
- underlineColor={theme.outline}
- style={[{ color: theme.onSurface }, styles.textInput]}
- theme={{ colors: { ...theme } }}
+ onPress={() => handleChange(key, !formValues[key])}
+ theme={theme}
/>
);
- })}
-
- opt.value === formValues[key],
+ );
+ const isMenuOpen = !!openMenus[key];
+ return (
+
+
+
+ );
+ }
+ if (setting?.type === 'CheckboxGroup') {
+ const value = (formValues[key] || []) as string[];
+ const isExpanded = !!openMenus[key];
+ return (
+
+
+ setOpenMenus(prev => ({ ...prev, [key]: !prev[key] }))
+ }
+ android_ripple={{ color: theme.rippleColor }}
+ >
+
+ {setting.label}
+
+
+
+ {isExpanded &&
+ setting.options.map(option => (
+
+ handleChange(
+ key,
+ insertOrRemoveIntoArray(value, option.value),
+ )
+ }
+ />
+ ))}
+
+ );
+ }
+ return (
+ handleChange(key, value)}
+ placeholder={`Enter ${setting.label}`}
+ placeholderTextColor={theme.onSurfaceDisabled}
+ underlineColor={theme.outline}
+ style={[{ color: theme.onSurface }, styles.textInput]}
+ theme={{ colors: { ...theme } }}
/>
-
-
-
+ );
+ })}
+
);
};
export default SourceSettingsModal;
const styles = StyleSheet.create({
- button: {
- flex: 1,
- marginHorizontal: 8,
- marginTop: 16,
- },
- customCSSButtons: {
- flexDirection: 'row',
- },
loadingIndicator: {
margin: 24,
},
- modalTitle: {
- fontSize: 24,
- marginBottom: 16,
- },
textInput: {
borderRadius: 14,
fontSize: 16,
diff --git a/src/screens/library/LibraryScreen.tsx b/src/screens/library/LibraryScreen.tsx
index 5b9f9d3d57..d8365f8ad4 100644
--- a/src/screens/library/LibraryScreen.tsx
+++ b/src/screens/library/LibraryScreen.tsx
@@ -1,7 +1,7 @@
+import { Button, SafeAreaView, SearchbarV2 } from '@components';
import { Actionbar } from '@components/Actionbar/Actionbar';
import { Row } from '@components/Common';
import { useLibraryContext } from '@components/Context/LibraryContext';
-import { Button, SafeAreaView, SearchbarV2 } from '@components/index';
import {
markAllChaptersRead,
markAllChaptersUnread,
diff --git a/src/screens/library/components/LibraryListView.tsx b/src/screens/library/components/LibraryListView.tsx
index efb01c2ecb..bfd2d00798 100644
--- a/src/screens/library/components/LibraryListView.tsx
+++ b/src/screens/library/components/LibraryListView.tsx
@@ -1,4 +1,4 @@
-import { EmptyView } from '@components/index';
+import { EmptyView } from '@components';
import { useNovelCoverLayout } from '@components/NovelCover';
import NovelList, { NovelListRenderItem } from '@components/NovelList';
import { NovelInfo } from '@database/types';
diff --git a/src/screens/novel/NovelScreen.tsx b/src/screens/novel/NovelScreen.tsx
index b0bc86fa8b..61fdee6623 100644
--- a/src/screens/novel/NovelScreen.tsx
+++ b/src/screens/novel/NovelScreen.tsx
@@ -441,40 +441,38 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
-
- {novel ? (
- <>
-
-
-
- showForceResetModal(false)}
- novel={novel}
- theme={theme}
- />
- >
- ) : null}
-
+ {novel ? (
+ <>
+
+
+
+ showForceResetModal(false)}
+ novel={novel}
+ theme={theme}
+ />
+ >
+ ) : null}
);
diff --git a/src/screens/novel/components/DownloadCustomChapterModal.tsx b/src/screens/novel/components/DownloadCustomChapterModal.tsx
index 300cb9f016..44274d64d0 100644
--- a/src/screens/novel/components/DownloadCustomChapterModal.tsx
+++ b/src/screens/novel/components/DownloadCustomChapterModal.tsx
@@ -1,11 +1,10 @@
-import { Modal } from '@components';
+import { KeyboardAvoidingModal, StableTextInput } from '@components';
import { ChapterInfo, NovelInfo } from '@database/types';
import { getString } from '@strings/translations';
import { ThemeColors } from '@theme/types';
import React, { useState } from 'react';
-import { StyleSheet, Text, TextInput, View } from 'react-native';
-import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
-import { Button, IconButton, Portal } from 'react-native-paper';
+import { StyleSheet, View } from 'react-native';
+import { IconButton } from 'react-native-paper';
interface DownloadCustomChapterModalProps {
theme: ThemeColors;
@@ -32,7 +31,6 @@ const DownloadCustomChapterModal = ({
};
const onSubmit = () => {
- hideModal();
downloadChapters(
novel,
chapters
@@ -48,73 +46,56 @@ const DownloadCustomChapterModal = ({
};
return (
-
-
-
-
- {getString('novelScreen.download.customAmount')}
-
-
- text > 9 && setText(prevState => prevState - 10)}
- />
- text > 0 && setText(prevState => prevState - 1)}
- />
-
- setText(prevState => prevState + 1)}
- />
- setText(prevState => prevState + 10)}
- />
-
-
- {getString('libraryScreen.bottomSheet.display.download')}
-
-
-
-
+
+
+ text > 9 && setText(prevState => prevState - 10)}
+ />
+ text > 0 && setText(prevState => prevState - 1)}
+ />
+
+ setText(prevState => prevState + 1)}
+ />
+ setText(prevState => prevState + 10)}
+ />
+
+
);
};
export default DownloadCustomChapterModal;
const styles = StyleSheet.create({
- errorText: {
- color: '#FF0033',
- paddingTop: 8,
- },
- modalTitle: {
- fontSize: 16,
- marginBottom: 16,
- },
row: { flexDirection: 'row', justifyContent: 'center' },
marginHorizontal: { marginHorizontal: 4 },
});
diff --git a/src/screens/novel/components/EditInfoModal.tsx b/src/screens/novel/components/EditInfoModal.tsx
index 3474041507..5a777b6d46 100644
--- a/src/screens/novel/components/EditInfoModal.tsx
+++ b/src/screens/novel/components/EditInfoModal.tsx
@@ -1,4 +1,4 @@
-import { KeyboardAvoidingModal } from '@components';
+import { KeyboardAvoidingModal, StableTextInput } from '@components';
import { updateNovelInfo } from '@database/queries/NovelQueries';
import { NovelInfo } from '@database/types';
import { NovelStatus } from '@plugins/types';
@@ -6,7 +6,7 @@ import MaterialCommunityIcons from '@react-native-vector-icons/material-design-i
import { getString } from '@strings/translations';
import { ThemeColors } from '@theme/types';
import { translateNovelStatus } from '@utils/translateEnum';
-import React, { useState } from 'react';
+import React, { useCallback, useMemo, useState } from 'react';
import {
FlatList,
Pressable,
@@ -15,7 +15,6 @@ import {
Text,
View,
} from 'react-native';
-import { TextInput } from 'react-native-paper';
interface EditInfoModalProps {
theme: ThemeColors;
@@ -47,7 +46,7 @@ const EditInfoModal = ({
novel,
setNovel,
}: EditInfoModalProps) => {
- const initialNovelInfo = { ...novel };
+ const initialNovelInfo = useMemo(() => ({ ...novel }), [novel]);
const [novelInfo, setNovelInfo] = useState(novel);
const [newGenre, setNewGenre] = useState('');
@@ -63,147 +62,156 @@ const EditInfoModal = ({
}));
};
+ const onReset = useCallback(() => {
+ setNovelInfo(initialNovelInfo);
+ updateNovelInfo(initialNovelInfo);
+ }, [initialNovelInfo]);
+
const status = Object.values(NovelStatus);
return (
{
+ onReset();
+ hideModal();
+ }}
onConfirm={() => {
setNovel(novelInfo);
updateNovelInfo(novelInfo);
}}
- onReset={() => {
- setNovelInfo(initialNovelInfo);
- updateNovelInfo(initialNovelInfo);
- }}
+ onReset={onReset}
>
-
-
- {getString('novelScreen.edit.status')}
-
-
- {status.map((item, index) => (
-
-
- setNovelInfo(prev => ({ ...prev, status: item }))
- }
- >
-
+
+ {getString('novelScreen.edit.status')}
+
+
+ {status.map((item, index) => (
+
+
+ setNovelInfo(prev => ({ ...prev, status: item }))
+ }
>
- {translateNovelStatus(item)}
-
-
-
- ))}
-
-
- setNovelInfo(prev => ({ ...prev, name: text }))}
- dense
- style={styles.inputWrapper}
- />
- setNovelInfo(prev => ({ ...prev, author: text }))}
- dense
- style={styles.inputWrapper}
- />
- setNovelInfo(prev => ({ ...prev, artist: text }))}
- dense
- style={styles.inputWrapper}
- />
-
- setNovelInfo(prev => ({ ...prev, summary: text }))
- }
- theme={{ colors: { ...theme } }}
- dense
- style={styles.inputWrapper}
- />
-
- setNewGenre(text)}
- onSubmitEditing={() => {
- const newGenreTrimmed = newGenre.trim();
-
- if (newGenreTrimmed === '') {
- return;
+
+ {translateNovelStatus(item)}
+
+
+
+ ))}
+
+
+ setNovelInfo(prev => ({ ...prev, name: text }))}
+ dense
+ style={styles.inputWrapper}
+ />
+
+ setNovelInfo(prev => ({ ...prev, author: text }))
}
+ dense
+ style={styles.inputWrapper}
+ />
+
+ setNovelInfo(prev => ({ ...prev, artist: text }))
+ }
+ dense
+ style={styles.inputWrapper}
+ />
+
+ setNovelInfo(prev => ({ ...prev, summary: text }))
+ }
+ theme={{ colors: { ...theme } }}
+ dense
+ style={styles.inputWrapper}
+ />
+
+ setNewGenre(text)}
+ onSubmitEditing={() => {
+ const newGenreTrimmed = newGenre.trim();
- setNovelInfo(prevVal => ({
- ...prevVal,
- genres: novelInfo.genres
- ? `${novelInfo.genres},` + newGenreTrimmed
- : newGenreTrimmed,
- }));
- setNewGenre('');
- setGenreKey(prev => prev + 1);
- }}
- theme={{ colors: { ...theme } }}
- dense
- style={styles.inputWrapper}
- />
+ if (newGenreTrimmed === '') {
+ return;
+ }
- {novelInfo.genres !== undefined && novelInfo.genres !== '' ? (
- 'novelTag' + index}
- renderItem={({ item }) => (
- removeTag(item)}>
- {item}
-
- )}
- showsHorizontalScrollIndicator={false}
+ setNovelInfo(prevVal => ({
+ ...prevVal,
+ genres: novelInfo.genres
+ ? `${novelInfo.genres},` + newGenreTrimmed
+ : newGenreTrimmed,
+ }));
+ setNewGenre('');
+ setGenreKey(prev => prev + 1);
+ }}
+ theme={{ colors: { ...theme } }}
+ dense
+ style={styles.inputWrapper}
/>
- ) : null}
+
+ {novelInfo.genres !== undefined && novelInfo.genres !== '' ? (
+ 'novelTag' + index}
+ renderItem={({ item }) => (
+ removeTag(item)}>
+ {item}
+
+ )}
+ showsHorizontalScrollIndicator={false}
+ />
+ ) : null}
);
};
diff --git a/src/screens/novel/components/ExportEpubModal.tsx b/src/screens/novel/components/ExportEpubModal.tsx
index 2677222c83..997397f45b 100644
--- a/src/screens/novel/components/ExportEpubModal.tsx
+++ b/src/screens/novel/components/ExportEpubModal.tsx
@@ -1,4 +1,4 @@
-import { Button, List, Modal, SwitchItem } from '@components';
+import { KeyboardAvoidingModal, List, StableTextInput, SwitchItem } from '@components';
import { useBoolean } from '@hooks';
import { useChapterReaderSettings, useTheme } from '@hooks/persisted';
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
@@ -6,7 +6,6 @@ import { getString } from '@strings/translations';
import { showToast } from '@utils/showToast';
import React, { useState } from 'react';
import { StyleSheet, View } from 'react-native';
-import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
import { Text, TextInput } from 'react-native-paper';
import { openDocumentTree } from 'react-native-saf-x';
import sanitizeFileName from 'sanitize-filename';
@@ -86,7 +85,7 @@ const ExportEpubModal: React.FC = ({
const validFileName = getValidFileName(fileName);
if (!validFileName) {
showToast(getString('novelScreen.exportEpubModal.invalidFileName'));
- return;
+ return false;
}
if (!exportAll.value) {
@@ -95,17 +94,17 @@ const ExportEpubModal: React.FC = ({
if (isNaN(start) || isNaN(end)) {
showToast(getString('novelScreen.exportEpubModal.invalidRange'));
- return;
+ return false;
}
if (start < 1 || end < 1) {
showToast(getString('novelScreen.exportEpubModal.invalidRange'));
- return;
+ return false;
}
if (start > end) {
showToast(getString('novelScreen.exportEpubModal.startGreaterThanEnd'));
- return;
+ return false;
}
}
@@ -120,7 +119,6 @@ const ExportEpubModal: React.FC = ({
const end = exportAll.value ? undefined : parseInt(endChapter, 10);
onSubmitProp?.(uri, validFileName, start, end);
- hideModal();
};
const openFolderPicker = async () => {
@@ -135,130 +133,115 @@ const ExportEpubModal: React.FC = ({
};
return (
-
-
-
-
- {getString('novelScreen.exportEpubModal.title')}
-
-
- }
- />
-
-
-
+
+
-
- {getString('novelScreen.exportEpubModal.overwriteWarning')}
-
-
-
-
-
- {!exportAll.value && (
-
-
-
-
- )}
-
-
-
+
+
+
+
+ {getString('novelScreen.exportEpubModal.overwriteWarning')}
+
-
+
+
-
-
-
-
-
-
+ {!exportAll.value && (
+
+
+
+
+ )}
+
+
+
+
+
+
);
};
@@ -274,15 +257,6 @@ const styles = StyleSheet.create({
fileNameInput: {
marginTop: 12,
},
- modalFooterCtn: {
- flexDirection: 'row-reverse',
- paddingBottom: 20,
- paddingTop: 8,
- },
- modalTitle: {
- fontSize: 24,
- marginBottom: 16,
- },
settings: {
marginTop: 12,
},
diff --git a/src/screens/novel/components/ExportNovelAsEpubButton.tsx b/src/screens/novel/components/ExportNovelAsEpubButton.tsx
index f47f5a80af..07ae681995 100644
--- a/src/screens/novel/components/ExportNovelAsEpubButton.tsx
+++ b/src/screens/novel/components/ExportNovelAsEpubButton.tsx
@@ -6,7 +6,6 @@ import { MaterialDesignIconName } from '@type/icon';
import { showToast } from '@utils/showToast';
import React, { useMemo } from 'react';
import { StatusBar, StyleProp, ViewStyle } from 'react-native';
-import { Portal } from 'react-native-paper';
import ExportEpubLogsModal from './ExportEpubLogsModal';
import ExportEpubModal from './ExportEpubModal';
@@ -132,28 +131,26 @@ const ExportNovelAsEpubButton: React.FC = ({
return (
<>
-
-
+ {novel && exportParams && (
+ setLogsModalVisible(false)}
+ novel={novel}
+ destinationUri={exportParams.destinationUri}
+ fileName={exportParams.fileName}
+ startChapter={exportParams.startChapter}
+ endChapter={exportParams.endChapter}
+ epubStylesheet={epubStylesheet}
+ epubJavaScript={epubJavaScript}
+ epubUseCustomJS={epubUseCustomJS}
/>
- {novel && exportParams && (
- setLogsModalVisible(false)}
- novel={novel}
- destinationUri={exportParams.destinationUri}
- fileName={exportParams.fileName}
- startChapter={exportParams.startChapter}
- endChapter={exportParams.endChapter}
- epubStylesheet={epubStylesheet}
- epubJavaScript={epubJavaScript}
- epubUseCustomJS={epubUseCustomJS}
- />
- )}
-
+ )}
>
);
};
diff --git a/src/screens/novel/components/JumpToChapterModal.tsx b/src/screens/novel/components/JumpToChapterModal.tsx
index 2b9b2e39a2..42074e61a4 100644
--- a/src/screens/novel/components/JumpToChapterModal.tsx
+++ b/src/screens/novel/components/JumpToChapterModal.tsx
@@ -1,4 +1,4 @@
-import { Button, Modal, SwitchItem } from '@components';
+import { Button, KeyboardAwareModal, SwitchItem } from '@components';
import {
getNovelChaptersByName,
getNovelChaptersByNumber,
@@ -20,7 +20,7 @@ import {
View,
} from 'react-native';
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
-import { Portal, Text } from 'react-native-paper';
+import { Text } from 'react-native-paper';
import { useNovelAction, useNovelValue } from '../NovelContext';
@@ -188,75 +188,77 @@ const JumpToChapterModal = ({
const borderWidth = inputFocused || error ? 2 : 1;
const margin = inputFocused || error ? 0 : 1;
return (
-
-
-
-
-
- {getString('novelScreen.jumpToChapterModal.jumpToChapter')}
-
- setInputFocused(true)}
- onBlur={() => setInputFocused(false)}
- style={[
- {
- color: theme.onBackground,
- backgroundColor: theme.background,
- borderColor: error
- ? theme.error
- : inputFocused
- ? theme.primary
- : theme.outline,
- borderWidth: borderWidth,
- margin: margin,
- },
- styles.textInput,
- ]}
- />
- {!!error && (
-
- {error}
-
- )}
- setOpenChapter(!openChapter)}
- />
- setMode(!mode)}
- />
-
-
- {result.length ? (
-
- `chapter_${item.id}`}
- contentContainerStyle={styles.listContentCtn}
- />
-
- ) : null}
+
-
+
+
+ }
+ >
+
+
+ setInputFocused(true)}
+ onBlur={() => setInputFocused(false)}
+ style={[
+ {
+ color: theme.onBackground,
+ backgroundColor: theme.background,
+ borderColor: error
+ ? theme.error
+ : inputFocused
+ ? theme.primary
+ : theme.outline,
+ borderWidth: borderWidth,
+ margin: margin,
+ },
+ styles.textInput,
+ ]}
+ />
+ {!!error && (
+
+ {error}
+
+ )}
+ setOpenChapter(!openChapter)}
+ />
+ setMode(!mode)}
+ />
+
+
+ {result.length ? (
+
+ `chapter_${item.id}`}
+ contentContainerStyle={styles.listContentCtn}
+ />
-
-
+ ) : null}
+
);
};
@@ -284,12 +286,10 @@ const styles = StyleSheet.create({
},
modalFooterCtn: {
flexDirection: 'row-reverse',
+ paddingHorizontal: 16,
+ paddingBottom: 16,
paddingTop: 8,
},
- modalTitle: {
- fontSize: 24,
- marginBottom: 16,
- },
textInput: {
borderRadius: 4,
borderStyle: 'solid',
diff --git a/src/screens/novel/components/SetCategoriesModal.tsx b/src/screens/novel/components/SetCategoriesModal.tsx
index 66522cd183..b77a3fac27 100644
--- a/src/screens/novel/components/SetCategoriesModal.tsx
+++ b/src/screens/novel/components/SetCategoriesModal.tsx
@@ -1,5 +1,5 @@
+import { Button, Modal } from '@components';
import { Checkbox } from '@components/Checkbox/Checkbox';
-import { Button, Modal } from '@components/index';
import { getCategoriesWithCount } from '@database/queries/CategoryQueries';
import { updateNovelCategories } from '@database/queries/NovelQueries';
import { Category, CCategory } from '@database/types';
diff --git a/src/screens/novel/components/Tracker/ScoreSelectors.tsx b/src/screens/novel/components/Tracker/ScoreSelectors.tsx
index a9403cff30..8493294f1e 100644
--- a/src/screens/novel/components/Tracker/ScoreSelectors.tsx
+++ b/src/screens/novel/components/Tracker/ScoreSelectors.tsx
@@ -1,8 +1,8 @@
+import { StableTextInput } from '@components';
import { RadioButton, RadioButtonGroup } from '@components/RadioButton';
import { useTheme } from '@hooks/persisted';
import React, { useEffect, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
-import { TextInput } from 'react-native-paper';
import {
getAniListScoreFormatting,
@@ -85,8 +85,8 @@ export const MangaUpdatesScoreSelector: React.FC = ({
Enter a score between 0 and 10 (decimals allowed, e.g., 7.5)
- = ({
onDismiss={onDismiss}
onConfirm={handleSave}
>
- = ({
const handleSave = () => {
onUpdateStatus(selectedStatus);
- onDismiss();
};
const handleValueChange = (value: string) => {
@@ -34,8 +31,12 @@ const SetTrackStatusDialog: React.FC = ({
};
return (
-
-
+
= ({
))}
-
- {getString('common.cancel')}
- {getString('common.save')}
-
-
+
);
};
export default SetTrackStatusDialog;
-
-const styles = StyleSheet.create({
- buttonContainer: {
- flexDirection: 'row',
- justifyContent: 'flex-end',
- gap: 8,
- marginTop: 16,
- },
-});
diff --git a/src/screens/novel/components/Tracker/TrackSearchDialog.tsx b/src/screens/novel/components/Tracker/TrackSearchDialog.tsx
index 92c503d6f7..4c77d490b4 100644
--- a/src/screens/novel/components/Tracker/TrackSearchDialog.tsx
+++ b/src/screens/novel/components/Tracker/TrackSearchDialog.tsx
@@ -1,4 +1,4 @@
-import { Button, Modal } from '@components';
+import { KeyboardAvoidingModal, StableTextInput } from '@components';
import { getTracker, useTheme } from '@hooks/persisted';
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
import { SearchResult } from '@services/Trackers';
@@ -6,9 +6,8 @@ import { getString } from '@strings/translations';
import { getErrorMessage } from '@utils/error';
import { showToast } from '@utils/showToast';
import React, { useCallback, useEffect, useState } from 'react';
-import { ActivityIndicator, Image, StyleSheet, Text, View } from 'react-native';
+import { ActivityIndicator, Image, StyleSheet, Text } from 'react-native';
import { ScrollView } from 'react-native-gesture-handler';
-import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
import { TextInput, TouchableRipple } from 'react-native-paper';
import { TrackSearchDialogProps } from './types';
@@ -69,8 +68,7 @@ const TrackSearchDialog: React.FC = ({
if (selectedNovel) {
onTrackNovel(tracker, selectedNovel);
}
- onDismiss();
- }, [selectedNovel, onTrackNovel, tracker, onDismiss]);
+ }, [selectedNovel, onTrackNovel, tracker]);
const renderSearchResultCard = useCallback(
(item: SearchResult) => {
@@ -122,65 +120,55 @@ const TrackSearchDialog: React.FC = ({
);
return (
-
-
-
- }
- />
-
- {loading ? (
-
- ) : (
- searchResults.map(renderSearchResultCard)
- )}
-
-
-
- {getString('common.remove')}
-
-
- {getString('common.cancel')}
- OK
-
-
-
-
+
+
+ }
+ />
+
+ {loading ? (
+
+ ) : (
+ searchResults.map(renderSearchResultCard)
+ )}
+
+
);
};
export default TrackSearchDialog;
const styles = StyleSheet.create({
- actionButtons: {
- flexDirection: 'row',
- },
- buttonContainer: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- marginTop: 30,
- },
checkIcon: {
position: 'absolute',
right: 8,
diff --git a/src/screens/novel/components/Tracker/TrackSheet.tsx b/src/screens/novel/components/Tracker/TrackSheet.tsx
index 425bc373f2..5e5f2f73ab 100644
--- a/src/screens/novel/components/Tracker/TrackSheet.tsx
+++ b/src/screens/novel/components/Tracker/TrackSheet.tsx
@@ -6,7 +6,7 @@ import { TrackerMetadata } from '@hooks/persisted/useTracker';
import { TrackerName, UserListStatus } from '@services/Trackers';
import React, { useCallback, useMemo, useState } from 'react';
import { ScrollView, StyleSheet, ToastAndroid, View } from 'react-native';
-import { overlay, Portal } from 'react-native-paper';
+import { overlay } from 'react-native-paper';
import { getStatusLabel, getTrackerIcon } from './constants';
import SetTrackChaptersDialog from './SetTrackChaptersDialog';
@@ -200,45 +200,43 @@ const TrackSheet: React.FC = ({ bottomSheetRef, novel }) => {
})}
-
- {activeTracker && (
- <>
- {getTrackedNovel(activeTracker.name) ? (
- <>
-
-
-
- >
- ) : (
-
+ {getTrackedNovel(activeTracker.name) ? (
+ <>
+
- )}
- >
- )}
-
+
+
+ >
+ ) : (
+
+ )}
+ >
+ )}
>
);
};
diff --git a/src/screens/novel/components/__tests__/ExportEpubModal.test.tsx b/src/screens/novel/components/__tests__/ExportEpubModal.test.tsx
index 2bc118ff54..8bedf76fb4 100644
--- a/src/screens/novel/components/__tests__/ExportEpubModal.test.tsx
+++ b/src/screens/novel/components/__tests__/ExportEpubModal.test.tsx
@@ -11,23 +11,42 @@ jest.mock('@components', () => {
const { Pressable, Text, View } = jest.requireActual('react-native');
return {
- Button: ({ onPress, title }: { onPress: () => void; title: string }) =>
- ReactModule.createElement(
- Pressable,
- { onPress },
- ReactModule.createElement(Text, null, title),
- ),
- List: {
- InfoItem: ({ title }: { title: string }) =>
- ReactModule.createElement(Text, null, title),
- },
- Modal: ({
+ KeyboardAvoidingModal: ({
children,
+ confirmLabel,
+ onConfirm,
+ onDismiss,
+ title,
visible,
}: {
children: React.ReactNode;
+ confirmLabel: string;
+ onConfirm: () => boolean | void;
+ onDismiss: () => void;
+ title: React.ReactNode;
visible: boolean;
- }) => (visible ? ReactModule.createElement(View, null, children) : null),
+ }) =>
+ visible
+ ? ReactModule.createElement(
+ View,
+ null,
+ ReactModule.createElement(Text, null, title),
+ children,
+ ReactModule.createElement(
+ Pressable,
+ {
+ onPress: () => {
+ if (onConfirm() !== false) onDismiss();
+ },
+ },
+ ReactModule.createElement(Text, null, confirmLabel),
+ ),
+ )
+ : null,
+ List: {
+ InfoItem: ({ title }: { title: string }) =>
+ ReactModule.createElement(Text, null, title),
+ },
SwitchItem: ({
label,
onPress,
@@ -75,13 +94,6 @@ jest.mock('@utils/showToast', () => ({
showToast: jest.fn(),
}));
-jest.mock('react-native-keyboard-controller', () => {
- const { ScrollView } = jest.requireActual('react-native');
- return {
- KeyboardAwareScrollView: ScrollView,
- };
-});
-
jest.mock('react-native-paper', () => {
const ReactModule = jest.requireActual('react');
const {
diff --git a/src/screens/reader/components/ChapterDrawer/index.tsx b/src/screens/reader/components/ChapterDrawer/index.tsx
index c9e0d22569..2aaaf5dbe2 100644
--- a/src/screens/reader/components/ChapterDrawer/index.tsx
+++ b/src/screens/reader/components/ChapterDrawer/index.tsx
@@ -1,4 +1,4 @@
-import { Button, LoadingScreenV2 } from '@components/index';
+import { Button, LoadingScreenV2 } from '@components';
import { useAppSettings, useTheme } from '@hooks/persisted';
import { LegendList, LegendListRef, ViewToken } from '@legendapp/list';
import { useNovelActions, useNovelValue } from '@screens/novel/NovelContext';
diff --git a/src/screens/reader/components/ReaderBottomSheet/ReaderFontPicker.tsx b/src/screens/reader/components/ReaderBottomSheet/ReaderFontPicker.tsx
index 182aa352d0..ecbb09ef66 100644
--- a/src/screens/reader/components/ReaderBottomSheet/ReaderFontPicker.tsx
+++ b/src/screens/reader/components/ReaderBottomSheet/ReaderFontPicker.tsx
@@ -1,4 +1,4 @@
-import { SelectableChip } from '@components/index';
+import { SelectableChip } from '@components';
import { useChapterReaderSettings, useTheme } from '@hooks/persisted';
import { getString } from '@strings/translations';
import { Font, readerFonts } from '@utils/constants/readerConstants';
diff --git a/src/screens/reader/components/ReaderBottomSheet/TTSTab.tsx b/src/screens/reader/components/ReaderBottomSheet/TTSTab.tsx
index 239987d8a4..2e9d0a119a 100644
--- a/src/screens/reader/components/ReaderBottomSheet/TTSTab.tsx
+++ b/src/screens/reader/components/ReaderBottomSheet/TTSTab.tsx
@@ -1,4 +1,4 @@
-import { Button, List } from '@components/index';
+import { Button, List } from '@components';
import { BottomSheetScrollView } from '@gorhom/bottom-sheet';
import {
useChapterGeneralSettings,
diff --git a/src/screens/reader/components/ReaderBottomSheet/TranslateTab.tsx b/src/screens/reader/components/ReaderBottomSheet/TranslateTab.tsx
index 8e90240d7a..78834d7155 100644
--- a/src/screens/reader/components/ReaderBottomSheet/TranslateTab.tsx
+++ b/src/screens/reader/components/ReaderBottomSheet/TranslateTab.tsx
@@ -1,4 +1,4 @@
-import { Button, List, SwitchItem } from '@components/index';
+import { Button, List, SwitchItem } from '@components';
import { BottomSheetScrollView } from '@gorhom/bottom-sheet';
import { useTheme } from '@hooks/persisted';
import { useAIProviders } from '@hooks/persisted/useAIProviders';
diff --git a/src/screens/reader/components/ReaderSearchbar.tsx b/src/screens/reader/components/ReaderSearchbar.tsx
index 46b0d4de55..a3ddf24133 100644
--- a/src/screens/reader/components/ReaderSearchbar.tsx
+++ b/src/screens/reader/components/ReaderSearchbar.tsx
@@ -1,8 +1,8 @@
-import { IconButtonV2 } from '@components';
+import { IconButtonV2, StableTextInput } from '@components';
import { getString } from '@strings/translations';
import { ThemeColors } from '@theme/types';
import React, { useEffect, useRef } from 'react';
-import { StyleSheet, Text, TextInput, View } from 'react-native';
+import { StyleSheet, Text, TextInput,View } from 'react-native';
import type { NativeChapterSearch } from '../hooks/useNativeChapterSearch';
@@ -33,7 +33,7 @@ const ReaderSearchbar = ({ theme, search }: ReaderSearchbarProps) => {
onPress={() => inputRef.current?.focus()}
theme={theme}
/>
- = ({
const [providerMenuVisible, setProviderMenuVisible] = useState(false);
const [apiModeMenuVisible, setApiModeMenuVisible] = useState(false);
const [reasoningMenuVisible, setReasoningMenuVisible] = useState(false);
- const [isSaving, setIsSaving] = useState(false);
- const [inputKey, setInputKey] = useState(0);
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [modelPickerVisible, setModelPickerVisible] = useState(false);
@@ -102,7 +99,6 @@ const AIProviderModal: React.FC = ({
getApiKey(initialProvider.id).then(key => {
if (mounted && key) {
setApiKey(key);
- setInputKey(k => k + 1);
}
});
} else if (visible && !initialProvider) {
@@ -115,7 +111,6 @@ const AIProviderModal: React.FC = ({
setApiMode('chat-completions');
setEnableReasoning(false);
setReasoningEffort('none');
- setInputKey(k => k + 1);
}
return () => {
@@ -126,14 +121,13 @@ const AIProviderModal: React.FC = ({
const handleSave = async () => {
if (!alias) {
showToast('Alias is required');
- return;
+ return false;
}
if (!apiKey && !initialProvider) {
showToast('API Key is required');
- return;
+ return false;
}
- setIsSaving(true);
try {
await onSave(
{
@@ -148,11 +142,9 @@ const AIProviderModal: React.FC = ({
},
apiKey,
);
- onDismiss();
} catch (e: any) {
showToast(`Error saving provider: ${e.message}`);
- } finally {
- setIsSaving(false);
+ return false;
}
};
@@ -187,34 +179,122 @@ const AIProviderModal: React.FC = ({
};
return (
-
-
+
-
-
- {initialProvider
- ? getString('aiSettingsScreen.editProvider')
- : getString('aiSettingsScreen.addProvider')}
+
+
+
+
+ {getString('readerScreen.bottomSheet.translateTab.provider')}
+
+
+
+
+
-
+ = ({
},
}}
/>
+
+
+ {provider !== 'gemini' && (
- {getString('readerScreen.bottomSheet.translateTab.provider')}
+ {getString('aiSettingsScreen.apiMode')}
+ )}
-
-
-
-
-
-
-
-
+ setEnableReasoning(!enableReasoning)}
+ theme={theme}
+ />
- {provider !== 'gemini' && (
-
-
- {getString('aiSettingsScreen.apiMode')}
-
-
+
+ ▼
+
+
+ }
+ >
+ {REASONING_EFFORTS.map(eff => (
{
- setApiMode('chat-completions');
- setApiModeMenuVisible(false);
+ setReasoningEffort(eff);
+ setReasoningMenuVisible(false);
}}
/>
-
-
- )}
-
- setEnableReasoning(!enableReasoning)}
- theme={theme}
- />
+ ))}
+
+
+ )}
+
- {enableReasoning && (
-
-
+ setModelPickerVisible(false)}
+ contentContainerStyle={[
+ styles.modalContent,
+ { backgroundColor: theme.surface },
+ ]}
+ >
+
+ Select Model
+
+
+ {availableModels.map(m => (
+ {
+ setModel(m);
+ setModelPickerVisible(false);
+ }}
>
- {getString('aiSettingsScreen.aiProviderModal.reasoningEffort')}
-
- setReasoningMenuVisible(false)}
- contentStyle={{ backgroundColor: theme.surface }}
- anchor={
- setReasoningMenuVisible(true)}
- >
-
- {reasoningEffort}
-
-
- ▼
-
-
- }
- >
- {REASONING_EFFORTS.map(eff => (
- {
- setReasoningEffort(eff);
- setReasoningMenuVisible(false);
- }}
- />
- ))}
-
-
- )}
-
-
+
+ {m}
+
+ {model === m && (
+
+ ✓
+
+ )}
+
+ ))}
+
-
- setModelPickerVisible(false)}
+ style={styles.cancelButton}
/>
-
-
-
- setModelPickerVisible(false)}
- contentContainerStyle={[
- styles.modalContent,
- { backgroundColor: theme.surface },
- ]}
- >
-
- Select Model
-
-
- {availableModels.map(m => (
- {
- setModel(m);
- setModelPickerVisible(false);
- setInputKey(k => k + 1);
- }}
- >
-
- {m}
-
- {model === m && (
-
- ✓
-
- )}
-
- ))}
-
- setModelPickerVisible(false)}
- style={styles.cancelButton}
- />
-
-
+
+
+ >
);
};
@@ -533,16 +486,6 @@ const styles = StyleSheet.create({
marginBottom: 12,
backgroundColor: 'transparent',
},
- footer: {
- flexDirection: 'row',
- marginTop: 8,
- },
- flexBtn: {
- flex: 1,
- },
- spacer: {
- width: 12,
- },
modalTitle: {
fontSize: 20,
fontWeight: 'bold',
diff --git a/src/screens/settings/SettingsAIScreen/components/PromptEditModal.tsx b/src/screens/settings/SettingsAIScreen/components/PromptEditModal.tsx
index 4907336db1..fb3f0af978 100644
--- a/src/screens/settings/SettingsAIScreen/components/PromptEditModal.tsx
+++ b/src/screens/settings/SettingsAIScreen/components/PromptEditModal.tsx
@@ -1,10 +1,8 @@
-import { Button } from '@components/index';
+import { KeyboardAvoidingModal, StableTextInput } from '@components';
import { useTheme } from '@hooks/persisted';
import { getString } from '@strings/translations';
import React, { useEffect, useState } from 'react';
-import { StyleSheet, Text, View } from 'react-native';
-import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
-import { Modal, Portal, TextInput } from 'react-native-paper';
+import { StyleSheet } from 'react-native';
interface PromptEditModalProps {
visible: boolean;
@@ -38,101 +36,62 @@ const PromptEditModal: React.FC = ({
const handleSave = () => {
onSave(initialPrompt?.id || null, title, content);
- onDismiss();
};
return (
-
-
-
-
- {initialPrompt
- ? getString('aiSettingsScreen.editPrompt')
- : getString('aiSettingsScreen.addPrompt')}
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
+
+
);
};
export default PromptEditModal;
const styles = StyleSheet.create({
- modalContent: {
- margin: 20,
- borderRadius: 8,
- padding: 20,
- maxHeight: '80%',
- },
- modalTitle: {
- fontSize: 20,
- fontWeight: 'bold',
- marginBottom: 16,
- },
input: {
marginBottom: 16,
},
@@ -140,15 +99,5 @@ const styles = StyleSheet.create({
minHeight: 150,
maxHeight: 250,
textAlignVertical: 'top',
- marginBottom: 24,
- },
- footer: {
- flexDirection: 'row',
- },
- flexBtn: {
- flex: 1,
- },
- spacer: {
- width: 12,
},
});
diff --git a/src/screens/settings/SettingsAIScreen/index.tsx b/src/screens/settings/SettingsAIScreen/index.tsx
index a5fe35f5cd..3401852770 100644
--- a/src/screens/settings/SettingsAIScreen/index.tsx
+++ b/src/screens/settings/SettingsAIScreen/index.tsx
@@ -4,8 +4,9 @@ import {
EmptyView,
List,
SafeAreaView,
+ StableTextInput,
SwitchItem,
-} from '@components/index';
+} from '@components';
import { useTheme } from '@hooks/persisted';
import { useAIProviders } from '@hooks/persisted/useAIProviders';
import {
@@ -16,7 +17,7 @@ import { SettingsAIScreenProps } from '@navigators/types';
import { getString } from '@strings/translations';
import { showToast } from '@utils/showToast';
import React, { useState } from 'react';
-import { StyleSheet, Text, TextInput, View } from 'react-native';
+import { StyleSheet, Text, View } from 'react-native';
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
import { IconButton, List as PaperList } from 'react-native-paper';
@@ -197,7 +198,7 @@ const SettingsAIScreen = ({ navigation }: SettingsAIScreenProps) => {
chunkWordLimitConfig,
)}
- {
backgroundColor: theme.surfaceVariant,
},
]}
- defaultValue={chunkWordLimitText}
+ value={chunkWordLimitText}
onChangeText={setChunkWordLimitText}
onBlur={() => {
const num = parseInt(chunkWordLimitText, 10);
@@ -254,7 +255,7 @@ const SettingsAIScreen = ({ navigation }: SettingsAIScreenProps) => {
retryMaxAttemptsConfig,
)}
- {
backgroundColor: theme.surfaceVariant,
},
]}
- defaultValue={retryAttemptsText}
+ value={retryAttemptsText}
onChangeText={setRetryAttemptsText}
onBlur={() => {
const num = parseInt(retryAttemptsText, 10);
diff --git a/src/screens/settings/SettingsAdvancedScreen.tsx b/src/screens/settings/SettingsAdvancedScreen.tsx
index c54319cce0..7c842fdae6 100644
--- a/src/screens/settings/SettingsAdvancedScreen.tsx
+++ b/src/screens/settings/SettingsAdvancedScreen.tsx
@@ -1,4 +1,4 @@
-import { Appbar, Button, List, Modal, SafeAreaView } from '@components';
+import { Appbar, KeyboardAvoidingModal, List, SafeAreaView, StableTextInput } from '@components';
import ConfirmationDialog from '@components/ConfirmationDialog/ConfirmationDialog';
import {
clearUpdates,
@@ -25,9 +25,8 @@ import { getString } from '@strings/translations';
import { MMKVStorage } from '@utils/mmkv/mmkv';
import { showToast } from '@utils/showToast';
import React, { useCallback, useState } from 'react';
-import { ScrollView, StyleSheet, View } from 'react-native';
-import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
-import { Portal, Text, TextInput } from 'react-native-paper';
+import { ScrollView, StyleSheet } from 'react-native';
+import { Portal, Text } from 'react-native-paper';
import DohProviderModal from './components/DohProviderModal';
import SettingSwitch from './components/SettingSwitch';
@@ -103,13 +102,12 @@ const AdvancedSettings = ({ navigation }: AdvancedSettingsScreenProps) => {
const normalizedUserAgent = userAgentInput.trim();
if (!NativeNetwork.isUserAgentValid(normalizedUserAgent)) {
showToast(getString('advancedSettingsScreen.invalidUserAgent'));
- return;
+ return false;
}
setUserAgent(normalizedUserAgent);
showToast(
getString('advancedSettingsScreen.userAgentRestartRequiredToast'),
);
- hideUserAgentModal();
};
const resetUserAgent = () => {
@@ -306,106 +304,91 @@ const AdvancedSettings = ({ navigation }: AdvancedSettingsScreenProps) => {
/>
-
-
-
- {
- await clearUpdates();
- MMKVStorage.set(
- NOVEL_UPDATE_RANDOM_KEY,
- Math.random().toString(36).substring(2, 15),
- );
- showToast(getString('advancedSettingsScreen.clearUpdatesMessage'));
- hideClearUpdatesDialog();
- }}
- onDismiss={hideClearUpdatesDialog}
- theme={theme}
- />
- {
- await CookieManager.clearAll();
- showToast(getString('advancedSettingsScreen.clearCookiesCleared'));
- }}
- onDismiss={hideClearCookiesDialog}
- theme={theme}
- />
- {
- store.clearAll();
- showToast(
- getString('advancedSettingsScreen.clearPluginSettingsCleared'),
- );
- }}
- onDismiss={hideClearPluginSettingsDialog}
- theme={theme}
- />
- {
- await deleteAllReadingTime();
- showToast(
- getString('advancedSettingsScreen.resetReadingTimeSuccess'),
- );
- }}
- onDismiss={hideResetReadingTimeDialog}
- theme={theme}
- />
+
+
+ {
+ await clearUpdates();
+ MMKVStorage.set(
+ NOVEL_UPDATE_RANDOM_KEY,
+ Math.random().toString(36).substring(2, 15),
+ );
+ showToast(getString('advancedSettingsScreen.clearUpdatesMessage'));
+ hideClearUpdatesDialog();
+ }}
+ onDismiss={hideClearUpdatesDialog}
+ theme={theme}
+ />
+ {
+ await CookieManager.clearAll();
+ showToast(getString('advancedSettingsScreen.clearCookiesCleared'));
+ }}
+ onDismiss={hideClearCookiesDialog}
+ theme={theme}
+ />
+ {
+ store.clearAll();
+ showToast(
+ getString('advancedSettingsScreen.clearPluginSettingsCleared'),
+ );
+ }}
+ onDismiss={hideClearPluginSettingsDialog}
+ theme={theme}
+ />
+ {
+ await deleteAllReadingTime();
+ showToast(
+ getString('advancedSettingsScreen.resetReadingTimeSuccess'),
+ );
+ }}
+ onDismiss={hideResetReadingTimeDialog}
+ theme={theme}
+ />
-
-
-
- {getString('advancedSettingsScreen.userAgent')}
-
- {userAgent}
-
-
-
-
-
-
-
+
+ {userAgent}
+
+
+
{
export default AdvancedSettings;
const styles = StyleSheet.create({
- button: {
- flex: 1,
- marginHorizontal: 8,
- marginTop: 16,
- },
- buttonGroup: {
- flexDirection: 'row-reverse',
- },
- modalTitle: {
- fontSize: 24,
- marginBottom: 16,
- },
textInput: {
borderRadius: 14,
fontSize: 12,
diff --git a/src/screens/settings/SettingsBackupScreen/Components/GoogleDriveModal.tsx b/src/screens/settings/SettingsBackupScreen/Components/GoogleDriveModal.tsx
index 3a2311fee2..82ff8d3a80 100644
--- a/src/screens/settings/SettingsBackupScreen/Components/GoogleDriveModal.tsx
+++ b/src/screens/settings/SettingsBackupScreen/Components/GoogleDriveModal.tsx
@@ -1,6 +1,6 @@
import { exists, getBackups, makeDir } from '@api/drive';
import { DriveFile } from '@api/drive/types';
-import { Button, EmptyView, Modal } from '@components';
+import { Button, EmptyView, KeyboardAwareModal, StableTextInput } from '@components';
import { GoogleSignin, User } from '@react-native-google-signin/google-signin';
import ServiceManager from '@services/ServiceManager';
import { getString } from '@strings/translations';
@@ -11,8 +11,6 @@ import * as Clipboard from 'expo-clipboard';
import React, { useCallback, useEffect, useState } from 'react';
import { Image, StyleSheet, Text, View } from 'react-native';
import { FlatList, Pressable } from 'react-native-gesture-handler';
-import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
-import { Portal, TextInput } from 'react-native-paper';
enum BackupModal {
UNAUTHORIZED,
@@ -116,8 +114,8 @@ function CreateBackup({
return (
<>
-
-
-
-
-
- {getString('backupScreen.drive.googleDriveBackup')}
-
- {
- if (user?.user.email) {
- Clipboard.setStringAsync(user.user.email).then(success => {
- if (success) {
- showToast(
- getString('common.copiedToClipboard', {
- name: user.user.email,
- }),
- );
- }
- });
- }
- }}
- >
- {user ? (
-
- ) : null}
-
-
- {renderModal()}
-
-
-
+
+
+ {getString('backupScreen.drive.googleDriveBackup')}
+
+ {
+ if (user?.user.email) {
+ Clipboard.setStringAsync(user.user.email).then(success => {
+ if (success) {
+ showToast(
+ getString('common.copiedToClipboard', {
+ name: user.user.email,
+ }),
+ );
+ }
+ });
+ }
+ }}
+ >
+ {user ? (
+
+ ) : null}
+
+
+ }
+ >
+ {renderModal()}
+
);
}
@@ -352,7 +351,6 @@ const styles = StyleSheet.create({
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'space-between',
- marginBottom: 16,
textAlignVertical: 'center',
},
fontSize: { fontSize: 12 },
diff --git a/src/screens/settings/SettingsBackupScreen/Components/SelfHostModal.tsx b/src/screens/settings/SettingsBackupScreen/Components/SelfHostModal.tsx
index 2e06c8900b..3d692cd19b 100644
--- a/src/screens/settings/SettingsBackupScreen/Components/SelfHostModal.tsx
+++ b/src/screens/settings/SettingsBackupScreen/Components/SelfHostModal.tsx
@@ -1,5 +1,5 @@
import { list } from '@api/remote';
-import { Button, EmptyView, Modal } from '@components';
+import { Button, EmptyView, KeyboardAwareModal, StableTextInput } from '@components';
import { useSelfHost } from '@hooks/persisted/useSelfHost';
import ServiceManager from '@services/ServiceManager';
import { getString } from '@strings/translations';
@@ -8,8 +8,6 @@ import { fetchTimeout } from '@utils/fetch/fetch';
import React, { useCallback, useEffect, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { FlatList } from 'react-native-gesture-handler';
-import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
-import { Portal, TextInput } from 'react-native-paper';
enum BackupModal {
SET_HOST,
@@ -39,8 +37,8 @@ function CreateBackup({
return (
<>
-
-
-
-
-
-
- {getString('backupScreen.remote.backup')}
-
-
- {renderModal()}
-
-
-
+
+ {renderModal()}
+
);
}
@@ -312,14 +305,4 @@ const styles = StyleSheet.create({
borderRadius: 16,
width: '100%',
},
- modalTitle: {
- fontSize: 24,
- },
- titleContainer: {
- alignItems: 'center',
- flexDirection: 'row',
- justifyContent: 'space-between',
- marginBottom: 16,
- textAlignVertical: 'center',
- },
});
diff --git a/src/screens/settings/SettingsGeneralScreen/modals/DownloadCooldownModal.tsx b/src/screens/settings/SettingsGeneralScreen/modals/DownloadCooldownModal.tsx
index 97c5b6ea57..312fff0763 100644
--- a/src/screens/settings/SettingsGeneralScreen/modals/DownloadCooldownModal.tsx
+++ b/src/screens/settings/SettingsGeneralScreen/modals/DownloadCooldownModal.tsx
@@ -1,12 +1,10 @@
-import { Modal } from '@components';
+import { KeyboardAvoidingModal, StableTextInput } from '@components';
import { useAppSettings } from '@hooks/persisted';
import { DEFAULT_CHAPTER_DOWNLOAD_COOLDOWN_MS } from '@hooks/persisted/useSettings';
import { getString } from '@strings/translations';
import { ThemeColors } from '@theme/types';
import React, { useEffect, useState } from 'react';
-import { StyleSheet, Text, TextInput, View } from 'react-native';
-import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
-import { Portal, TouchableRipple } from 'react-native-paper';
+import { StyleSheet, Text } from 'react-native';
interface DownloadCooldownModalProps {
visible: boolean;
@@ -60,83 +58,54 @@ const DownloadCooldownModal: React.FC = ({
const save = () => {
const ms = parseSecondsToMs(draft);
if (ms == null) {
- hideModal();
- return;
+ return false;
}
setAppSettings({ chapterDownloadCooldownMs: ms });
- hideModal();
};
const reset = () => {
setAppSettings({
chapterDownloadCooldownMs: DEFAULT_CHAPTER_DOWNLOAD_COOLDOWN_MS,
});
- hideModal();
+ setDraft(msToSeconds(DEFAULT_CHAPTER_DOWNLOAD_COOLDOWN_MS));
};
return (
-
-
-
-
- {getString('generalSettingsScreen.chapterDownloadCooldown')}
-
-
- {getString('generalSettingsScreen.chapterDownloadCooldownDesc')}
-
- setDraft(sanitizeNumericInput(text))}
- onSubmitEditing={save}
- keyboardType="decimal-pad"
- placeholder={getString(
- 'generalSettingsScreen.chapterDownloadCooldownPlaceholder',
- )}
- placeholderTextColor={theme.onSurfaceVariant}
- style={[
- styles.input,
- { color: theme.onSurface, borderColor: theme.outline },
- ]}
- autoFocus
- />
-
- {getString('generalSettingsScreen.chapterDownloadCooldownWarning')}
-
-
-
-
- {getString('common.reset')}
-
-
-
-
- {getString('common.ok')}
-
-
-
-
-
-
+
+
+ {getString('generalSettingsScreen.chapterDownloadCooldownDesc')}
+
+ setDraft(sanitizeNumericInput(text))}
+ keyboardType="decimal-pad"
+ placeholder={getString(
+ 'generalSettingsScreen.chapterDownloadCooldownPlaceholder',
+ )}
+ placeholderTextColor={theme.onSurfaceVariant}
+ style={[
+ styles.input,
+ { color: theme.onSurface, borderColor: theme.outline },
+ ]}
+ autoFocus
+ />
+
+ {getString('generalSettingsScreen.chapterDownloadCooldownWarning')}
+
+
);
};
export default DownloadCooldownModal;
const styles = StyleSheet.create({
- modalHeader: {
- fontSize: 24,
- marginBottom: 8,
- },
modalDesc: {
fontSize: 14,
marginBottom: 16,
@@ -152,19 +121,4 @@ const styles = StyleSheet.create({
fontSize: 12,
marginTop: 12,
},
- actions: {
- flexDirection: 'row',
- justifyContent: 'flex-end',
- paddingTop: 16,
- },
- actionButton: {
- paddingHorizontal: 16,
- paddingVertical: 10,
- borderRadius: 8,
- },
- actionLabel: {
- fontSize: 14,
- fontWeight: '600',
- textTransform: 'uppercase',
- },
});
diff --git a/src/screens/settings/SettingsReaderScreen/Modals/VoicePickerModal.tsx b/src/screens/settings/SettingsReaderScreen/Modals/VoicePickerModal.tsx
index d2d0527974..5c8fff692d 100644
--- a/src/screens/settings/SettingsReaderScreen/Modals/VoicePickerModal.tsx
+++ b/src/screens/settings/SettingsReaderScreen/Modals/VoicePickerModal.tsx
@@ -1,11 +1,11 @@
-import { Modal } from '@components';
+import { KeyboardAwareModal, StableTextInput } from '@components';
import { RadioButton } from '@components/RadioButton/RadioButton';
import { useChapterReaderSettings, useTheme } from '@hooks/persisted';
import { LegendList } from '@legendapp/list';
import { Voice } from 'expo-speech';
import React, { useState } from 'react';
import { StyleSheet } from 'react-native';
-import { ActivityIndicator, Portal, TextInput } from 'react-native-paper';
+import { ActivityIndicator } from 'react-native-paper';
interface VoicePickerModalProps {
visible: boolean;
@@ -24,62 +24,62 @@ const VoicePickerModal: React.FC = ({
const { setChapterReaderSettings, tts } = useChapterReaderSettings();
return (
-
-
- {
- setSearchText(text);
- setSearchedVoices(
- voices.filter(voice =>
- voice.name
- .toLocaleLowerCase()
- .includes(text.toLocaleLowerCase()),
- ),
- );
- }}
- defaultValue={searchText}
- placeholder="Search voice"
- />
- }
- ListHeaderComponentStyle={styles.paddingHorizontal}
- data={searchText ? searchedVoices : voices}
- extraData={tts?.voice}
- renderItem={({ item }) => (
-
- setChapterReaderSettings({ tts: { ...tts, voice: item } })
- }
- label={item.name + ` (${item.language})`}
- labelStyle={{ fontFamily: item.name }}
- theme={theme}
- />
- )}
- keyExtractor={(item, index) =>
- item.identifier || `voice_${index}_${item.name}`
- }
- estimatedItemSize={64}
- ListEmptyComponent={
-
- }
- />
-
-
+
+ {
+ setSearchText(text);
+ setSearchedVoices(
+ voices.filter(voice =>
+ voice.name
+ .toLocaleLowerCase()
+ .includes(text.toLocaleLowerCase()),
+ ),
+ );
+ }}
+ value={searchText}
+ placeholder="Search voice"
+ />
+ }
+ ListHeaderComponentStyle={styles.paddingHorizontal}
+ data={searchText ? searchedVoices : voices}
+ extraData={tts?.voice}
+ renderItem={({ item }) => (
+
+ setChapterReaderSettings({ tts: { ...tts, voice: item } })
+ }
+ label={item.name + ` (${item.language})`}
+ labelStyle={{ fontFamily: item.name }}
+ theme={theme}
+ />
+ )}
+ keyExtractor={(item, index) =>
+ item.identifier || `voice_${index}_${item.name}`
+ }
+ estimatedItemSize={64}
+ ListEmptyComponent={
+
+ }
+ />
+
);
};
@@ -89,6 +89,9 @@ const styles = StyleSheet.create({
containerStyle: {
flex: 1,
},
+ contentContainer: {
+ flex: 1,
+ },
paddingHorizontal: { paddingHorizontal: 12 },
marginTop: { marginTop: 16 },
});
diff --git a/src/screens/settings/SettingsReaderScreen/ReaderTextSize.tsx b/src/screens/settings/SettingsReaderScreen/ReaderTextSize.tsx
index 5d3d52f0b3..1eea4ad78b 100644
--- a/src/screens/settings/SettingsReaderScreen/ReaderTextSize.tsx
+++ b/src/screens/settings/SettingsReaderScreen/ReaderTextSize.tsx
@@ -1,4 +1,4 @@
-import { IconButtonV2 } from '@components/index';
+import { IconButtonV2 } from '@components';
import { useChapterReaderSettings, useTheme } from '@hooks/persisted';
import { getString } from '@strings/translations';
import React from 'react';
diff --git a/src/screens/settings/SettingsReaderScreen/SettingsReaderScreen.tsx b/src/screens/settings/SettingsReaderScreen/SettingsReaderScreen.tsx
index ca32811997..e2ca85e82b 100644
--- a/src/screens/settings/SettingsReaderScreen/SettingsReaderScreen.tsx
+++ b/src/screens/settings/SettingsReaderScreen/SettingsReaderScreen.tsx
@@ -1,5 +1,5 @@
+import { Appbar, SafeAreaView } from '@components';
import BottomSheet from '@components/BottomSheet/BottomSheet';
-import { Appbar, SafeAreaView } from '@components/index';
import { BottomSheetModal } from '@gorhom/bottom-sheet';
import {
useChapterGeneralSettings,
diff --git a/src/screens/settings/SettingsReaderScreen/tabs/AccessibilityTab.tsx b/src/screens/settings/SettingsReaderScreen/tabs/AccessibilityTab.tsx
index 6a489ba8bf..b1804fa65a 100644
--- a/src/screens/settings/SettingsReaderScreen/tabs/AccessibilityTab.tsx
+++ b/src/screens/settings/SettingsReaderScreen/tabs/AccessibilityTab.tsx
@@ -1,4 +1,4 @@
-import { List } from '@components/index';
+import { List } from '@components';
import { BottomSheetScrollView } from '@gorhom/bottom-sheet';
import { useChapterGeneralSettings, useTheme } from '@hooks/persisted';
import { getString } from '@strings/translations';
diff --git a/src/screens/settings/SettingsReaderScreen/tabs/AdvancedTab.tsx b/src/screens/settings/SettingsReaderScreen/tabs/AdvancedTab.tsx
index d847623351..779937f934 100644
--- a/src/screens/settings/SettingsReaderScreen/tabs/AdvancedTab.tsx
+++ b/src/screens/settings/SettingsReaderScreen/tabs/AdvancedTab.tsx
@@ -1,4 +1,4 @@
-import { Button, ConfirmationDialog } from '@components/index';
+import { Button, ConfirmationDialog, StableTextInput } from '@components';
import {
BottomSheetScrollView,
BottomSheetTextInput,
@@ -13,7 +13,7 @@ import { showToast } from '@utils/showToast';
import * as DocumentPicker from 'expo-document-picker';
import React, { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
-import { Portal, TextInput } from 'react-native-paper';
+import { Portal } from 'react-native-paper';
type CodeTab = 'css' | 'js';
@@ -209,10 +209,10 @@ if (title) {
{/* Code Editor */}
- }
mode="flat"
- defaultValue={activeCodeTab === 'css' ? cssValue : jsValue}
+ value={activeCodeTab === 'css' ? cssValue : jsValue}
onChangeText={text =>
activeCodeTab === 'css' ? setCssValue(text) : setJsValue(text)
}
diff --git a/src/screens/settings/SettingsReaderScreen/tabs/DisplayTab.tsx b/src/screens/settings/SettingsReaderScreen/tabs/DisplayTab.tsx
index c0a3940d55..7f14a665e9 100644
--- a/src/screens/settings/SettingsReaderScreen/tabs/DisplayTab.tsx
+++ b/src/screens/settings/SettingsReaderScreen/tabs/DisplayTab.tsx
@@ -1,4 +1,4 @@
-import { List } from '@components/index';
+import { List } from '@components';
import { BottomSheetScrollView } from '@gorhom/bottom-sheet';
import { useBoolean } from '@hooks';
import { useChapterReaderSettings, useTheme } from '@hooks/persisted';
diff --git a/src/screens/settings/SettingsReaderScreen/tabs/NavigationTab.tsx b/src/screens/settings/SettingsReaderScreen/tabs/NavigationTab.tsx
index cd1672db3d..9aa67099db 100644
--- a/src/screens/settings/SettingsReaderScreen/tabs/NavigationTab.tsx
+++ b/src/screens/settings/SettingsReaderScreen/tabs/NavigationTab.tsx
@@ -1,4 +1,4 @@
-import { Button, List } from '@components/index';
+import { Button, List, StableTextInput } from '@components';
import {
BottomSheetScrollView,
BottomSheetTextInput,
@@ -8,7 +8,6 @@ import { getString } from '@strings/translations';
import { defaultTo } from 'lodash-es';
import React from 'react';
import { StyleSheet, useWindowDimensions, View } from 'react-native';
-import { TextInput } from 'react-native-paper';
import SettingSwitch from '../../components/SettingSwitch';
@@ -51,12 +50,12 @@ const NavigationTab: React.FC = () => {
/>
{useVolumeButtons && (
- }
label={getString('readerSettings.volumeButtonOffset')}
mode="outlined"
keyboardType="numeric"
- defaultValue={defaultTo(
+ value={defaultTo(
volumeButtonsOffset
? Math.round(volumeButtonsOffset / screenHeight)
: null,
@@ -139,12 +138,12 @@ const NavigationTab: React.FC = () => {
{autoScroll && (
<>
- }
label={getString('readerSettings.autoScrollInterval')}
mode="outlined"
keyboardType="numeric"
- defaultValue={defaultTo(autoScrollInterval, 10).toString()}
+ value={defaultTo(autoScrollInterval, 10).toString()}
onChangeText={text => {
if (text) {
setChapterGeneralSettings({
@@ -157,12 +156,12 @@ const NavigationTab: React.FC = () => {
/>
- }
label={getString('readerSettings.autoScrollOffset')}
mode="outlined"
keyboardType="numeric"
- defaultValue={defaultTo(
+ value={defaultTo(
autoScrollOffset,
Math.round(screenHeight),
).toString()}
diff --git a/src/screens/settings/SettingsReaderScreen/tabs/ThemeTab.tsx b/src/screens/settings/SettingsReaderScreen/tabs/ThemeTab.tsx
index 829e1ba714..b1522cbd52 100644
--- a/src/screens/settings/SettingsReaderScreen/tabs/ThemeTab.tsx
+++ b/src/screens/settings/SettingsReaderScreen/tabs/ThemeTab.tsx
@@ -1,5 +1,5 @@
+import { Button, ColorPreferenceItem, List } from '@components';
import ColorPickerModal from '@components/ColorPickerModal/ColorPickerModal';
-import { Button, ColorPreferenceItem, List } from '@components/index';
import { BottomSheetScrollView } from '@gorhom/bottom-sheet';
import { useBoolean } from '@hooks';
import { useChapterReaderSettings, useTheme } from '@hooks/persisted';
@@ -8,7 +8,6 @@ import { getString } from '@strings/translations';
import { presetReaderThemes } from '@utils/constants/readerConstants';
import React from 'react';
import { StyleSheet, View } from 'react-native';
-import { Portal } from 'react-native-paper';
const ThemeTab: React.FC = () => {
const theme = useTheme();
@@ -94,28 +93,26 @@ const ThemeTab: React.FC = () => {
-
-
- readerSettings.setChapterReaderSettings({ theme: color })
- }
- />
-
- readerSettings.setChapterReaderSettings({ textColor: color })
- }
- />
-
+
+ readerSettings.setChapterReaderSettings({ theme: color })
+ }
+ />
+
+ readerSettings.setChapterReaderSettings({ textColor: color })
+ }
+ />
>
);
};
diff --git a/src/screens/settings/SettingsRepositoryScreen/SettingsRepositoryScreen.tsx b/src/screens/settings/SettingsRepositoryScreen/SettingsRepositoryScreen.tsx
index a0e373e1e7..55a6a50036 100644
--- a/src/screens/settings/SettingsRepositoryScreen/SettingsRepositoryScreen.tsx
+++ b/src/screens/settings/SettingsRepositoryScreen/SettingsRepositoryScreen.tsx
@@ -18,7 +18,7 @@ import { getString } from '@strings/translations';
import { showToast } from '@utils/showToast';
import React, { useCallback, useEffect } from 'react';
import { FlatList, StyleSheet } from 'react-native';
-import { FAB, Portal } from 'react-native-paper';
+import { FAB } from 'react-native-paper';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import AddRepositoryModal from './components/AddRepositoryModal';
@@ -118,13 +118,11 @@ const SettingsBrowseScreen = ({
onPress={showAddRepositoryModal}
icon={'plus'}
/>
-
-
-
+
);
};
diff --git a/src/screens/settings/SettingsRepositoryScreen/components/AddRepositoryModal.tsx b/src/screens/settings/SettingsRepositoryScreen/components/AddRepositoryModal.tsx
index 92c77466ef..57f9b49fd2 100644
--- a/src/screens/settings/SettingsRepositoryScreen/components/AddRepositoryModal.tsx
+++ b/src/screens/settings/SettingsRepositoryScreen/components/AddRepositoryModal.tsx
@@ -1,9 +1,8 @@
-import { KeyboardAvoidingModal } from '@components';
+import { KeyboardAvoidingModal, StableTextInput } from '@components';
import { Repository } from '@database/types';
import { useTheme } from '@hooks/persisted';
import { getString } from '@strings/translations';
import React, { useState } from 'react';
-import { TextInput } from 'react-native-paper';
interface AddRepositoryModalProps {
repository?: Repository;
@@ -29,9 +28,9 @@ const AddRepositoryModal: React.FC = ({
onDismiss={closeModal}
onConfirm={() => upsertRepository(repositoryUrl, repository)}
>
- {
}
setTracker(credentialLoginTracker, auth);
- hideCredentialLogin();
showToast(`Successfully logged in to ${credentialLoginTracker}`);
} catch (error) {
if (error instanceof Error) {
@@ -351,16 +350,16 @@ const TrackerScreen = ({ navigation }: TrackerSettingsScreenProps) => {
-
+
diff --git a/src/screens/settings/components/ConnectionModal.tsx b/src/screens/settings/components/ConnectionModal.tsx
index 1bab2aa7fd..7ffae578a1 100644
--- a/src/screens/settings/components/ConnectionModal.tsx
+++ b/src/screens/settings/components/ConnectionModal.tsx
@@ -1,8 +1,7 @@
-import { KeyboardAvoidingModal } from '@components';
+import { KeyboardAvoidingModal, StableTextInput } from '@components';
import { getString } from '@strings/translations';
import { ThemeColors } from '@theme/types';
import React from 'react';
-import { TextInput } from 'react-native-paper';
interface ConnectionModalProps {
title: string;
@@ -35,8 +34,8 @@ const ConnectionModal: React.FC = ({
onDismiss={closeModal}
onConfirm={() => handle(ipv4, port)}
>
- = ({
theme={{ colors: { ...theme } }}
placeholderTextColor={theme.onSurfaceDisabled}
/>
- = ({
const handleSubmit = async () => {
if (!username.trim() || !password.trim()) {
setError(`${usernameLabel} and password are required`);
- return;
+ return false;
}
setIsLoading(true);
@@ -38,11 +37,12 @@ const TrackerLoginDialog: React.FC = ({
try {
await onSubmit(username.trim(), password);
- /* Clear form on success */
setUsername('');
setPassword('');
+ return true;
} catch (err) {
setError(err instanceof Error ? err.message : 'Authentication failed');
+ return false;
} finally {
setIsLoading(false);
}
@@ -56,92 +56,61 @@ const TrackerLoginDialog: React.FC = ({
};
return (
-
-
-
-
- Login to {trackerName}
-
+
+
-
+
-
-
- {error ? (
-
- {error}
-
- ) : null}
-
-
-
- Cancel
-
-
- {isLoading ? 'Logging in...' : 'Login'}
-
-
-
-
-
+ {error ? (
+ {error}
+ ) : null}
+
);
};
export default TrackerLoginDialog;
const styles = StyleSheet.create({
- container: {
- padding: 8,
- },
- title: {
- fontSize: 24,
- marginBottom: 24,
- fontWeight: '500',
- },
input: {
height: 48,
borderWidth: 1,
@@ -155,16 +124,4 @@ const styles = StyleSheet.create({
marginBottom: 16,
marginTop: -8,
},
- buttonRow: {
- flexDirection: 'row',
- justifyContent: 'flex-end',
- marginTop: 8,
- },
- button: {
- marginLeft: 8,
- },
- buttonLabel: {
- letterSpacing: 0,
- textTransform: 'none',
- },
});
diff --git a/strings/languages/en/strings.json b/strings/languages/en/strings.json
index 7462e41a68..e4e0514274 100644
--- a/strings/languages/en/strings.json
+++ b/strings/languages/en/strings.json
@@ -288,6 +288,7 @@
"header": "Delete category"
},
"duplicateError": "A category with this name already exists!",
+ "emptyError": "Category name cannot be empty!",
"editCategories": "Rename category",
"emptyMsg": "You have no categories. Tap the plus button to create one for organizing your library",
"header": "Edit categories",
diff --git a/strings/languages/vi_VN/strings.json b/strings/languages/vi_VN/strings.json
index 61f4bc06e0..9e0c1f3d8e 100644
--- a/strings/languages/vi_VN/strings.json
+++ b/strings/languages/vi_VN/strings.json
@@ -287,6 +287,7 @@
"header": "Xóa danh mục"
},
"duplicateError": "Danh mục với tên này đã tồn tại!",
+ "emptyError": "Tên danh mục không được để trống!",
"editCategories": "Đổi tên danh mục",
"emptyMsg": "Bạn không có danh mục nào. Hãy ấn vào nút thêm để tạo danh mục mới",
"header": "Chỉnh sửa danh mục",
diff --git a/strings/types/index.ts b/strings/types/index.ts
index 3ebb018aad..856be368a3 100644
--- a/strings/types/index.ts
+++ b/strings/types/index.ts
@@ -261,6 +261,7 @@ export interface StringMap {
'categories.deleteModal.desc': 'string';
'categories.deleteModal.header': 'string';
'categories.duplicateError': 'string';
+ 'categories.emptyError': 'string';
'categories.editCategories': 'string';
'categories.emptyMsg': 'string';
'categories.header': 'string';
From c6dd65b3ad2d30cf3a496f4b79aa2a36a4405f1c Mon Sep 17 00:00:00 2001
From: Elysia <71698422+aiko-chan-ai@users.noreply.github.com>
Date: Fri, 17 Jul 2026 22:28:51 +0700
Subject: [PATCH 5/7] fix: StableTextInput
---
package.json | 1 +
pnpm-lock.yaml | 24 ++
.../ColorPickerModal/ColorPickerModal.tsx | 2 +-
src/components/SearchbarV2/SearchbarV2.tsx | 2 +-
.../__tests__/StableTextInput.test.tsx | 84 ++++++
src/components/TextInput/index.tsx | 121 ++++++---
.../components/FilterBottomSheet.tsx | 2 +-
.../__tests__/AddCategoryModal.test.tsx | 22 +-
src/screens/novel/NovelScreen.tsx | 5 +-
.../novel/components/EditInfoModal.tsx | 244 +++++++++---------
.../novel/components/ExportEpubModal.tsx | 7 +-
.../__tests__/ExportEpubModal.test.tsx | 20 +-
.../reader/components/ReaderSearchbar.tsx | 2 +-
.../TranslatePromptScreen.tsx | 8 +-
.../components/AIProviderModal.tsx | 7 +-
.../settings/SettingsAdvancedScreen.tsx | 8 +-
.../Components/GoogleDriveModal.tsx | 7 +-
.../Components/SelfHostModal.tsx | 7 +-
18 files changed, 380 insertions(+), 193 deletions(-)
create mode 100644 src/components/TextInput/__tests__/StableTextInput.test.tsx
diff --git a/package.json b/package.json
index b7d77160da..8ec124758b 100644
--- a/package.json
+++ b/package.json
@@ -121,6 +121,7 @@
"react-native-url-polyfill": "^3.0.0",
"react-native-webview": "^13.16.1",
"react-native-worklets": "^0.8.1",
+ "reanimated-color-picker": "^5.1.2",
"sanitize-filename": "^1.6.4",
"sanitize-html": "^2.17.2",
"unicode-segmenter": "^0.16.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5cce886b21..8bab34a3da 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -259,6 +259,9 @@ importers:
react-native-worklets:
specifier: ^0.8.1
version: 0.8.1(@babel/core@7.29.0)(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
+ reanimated-color-picker:
+ specifier: ^5.1.2
+ version: 5.1.2(expo@55.0.9)(react-native-gesture-handler@2.30.1(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-reanimated@4.3.0(react-native-worklets@0.8.1(@babel/core@7.29.0)(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
sanitize-filename:
specifier: ^1.6.4
version: 1.6.4
@@ -5701,6 +5704,18 @@ packages:
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ reanimated-color-picker@5.1.2:
+ resolution: {integrity: sha512-AfGh9OIU0Y7/Yf5xoH7YwrXbyEyXjXOTP79OilSiw0OGvP0xM/h/Ij6lmyygzr/HQl2WHlhdhSAExk1hThIUeA==}
+ peerDependencies:
+ expo: '>=44.0.0'
+ react: '*'
+ react-native: '*'
+ react-native-gesture-handler: '>=2.0.0'
+ react-native-reanimated: '>=2.0.0'
+ peerDependenciesMeta:
+ expo:
+ optional: true
+
redent@3.0.0:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
engines: {node: '>=8'}
@@ -13392,6 +13407,15 @@ snapshots:
process: 0.11.10
string_decoder: 1.3.0
+ reanimated-color-picker@5.1.2(expo@55.0.9)(react-native-gesture-handler@2.30.1(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-reanimated@4.3.0(react-native-worklets@0.8.1(@babel/core@7.29.0)(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4):
+ dependencies:
+ react: 19.2.4
+ react-native: 0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4)
+ react-native-gesture-handler: 2.30.1(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
+ react-native-reanimated: 4.3.0(react-native-worklets@0.8.1(@babel/core@7.29.0)(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
+ optionalDependencies:
+ expo: 55.0.9(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-native-webview@13.16.1(patch_hash=9fbad2ce93fe42b331382c823e5f2ad0cd5fd682b80b0aafe804ba0f8aff105a)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
+
redent@3.0.0:
dependencies:
indent-string: 4.0.0
diff --git a/src/components/ColorPickerModal/ColorPickerModal.tsx b/src/components/ColorPickerModal/ColorPickerModal.tsx
index aafbac1fe8..d95e4e3d11 100644
--- a/src/components/ColorPickerModal/ColorPickerModal.tsx
+++ b/src/components/ColorPickerModal/ColorPickerModal.tsx
@@ -1,6 +1,6 @@
import { KeyboardAvoidingModal, StableTextInput } from '@components';
import React, { useState } from 'react';
-import { FlatList, Pressable,StyleSheet, Text, View } from 'react-native';
+import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
import { ThemeColors } from '../../theme/types';
diff --git a/src/components/SearchbarV2/SearchbarV2.tsx b/src/components/SearchbarV2/SearchbarV2.tsx
index 06c130fff5..e4a17a597c 100644
--- a/src/components/SearchbarV2/SearchbarV2.tsx
+++ b/src/components/SearchbarV2/SearchbarV2.tsx
@@ -1,6 +1,6 @@
import { MaterialDesignIconName } from '@type/icon';
import React, { memo, useRef, useState } from 'react';
-import { Pressable, StyleSheet, TextInput,View } from 'react-native';
+import { Pressable, StyleSheet, TextInput, View } from 'react-native';
import { ThemeColors } from '../../theme/types';
import IconButtonV2 from '../IconButtonV2/IconButtonV2';
diff --git a/src/components/TextInput/__tests__/StableTextInput.test.tsx b/src/components/TextInput/__tests__/StableTextInput.test.tsx
new file mode 100644
index 0000000000..e7cd2bc748
--- /dev/null
+++ b/src/components/TextInput/__tests__/StableTextInput.test.tsx
@@ -0,0 +1,84 @@
+import { fireEvent, render, screen } from '@testing-library/react-native';
+import React from 'react';
+
+import StableTextInput from '../index';
+
+const mockFocus = jest.fn();
+const mockIsFocused = jest.fn(() => false);
+const mockSetSelection = jest.fn();
+let mockLatestProps: Record = {};
+let mockMountCount = 0;
+
+jest.mock('react-native-paper', () => {
+ const ReactModule = jest.requireActual('react');
+ const { TextInput: NativeTextInput } = jest.requireActual('react-native');
+
+ const TextInput = ReactModule.forwardRef(
+ (props: Record, ref: React.Ref) => {
+ mockLatestProps = props;
+
+ ReactModule.useEffect(() => {
+ mockMountCount += 1;
+ }, []);
+
+ ReactModule.useImperativeHandle(ref, () => ({
+ focus: mockFocus,
+ isFocused: mockIsFocused,
+ setSelection: mockSetSelection,
+ }));
+
+ return ReactModule.createElement(NativeTextInput, {
+ ...props,
+ testID: 'stable-input',
+ });
+ },
+ );
+
+ return { TextInput };
+});
+
+describe('StableTextInput', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockIsFocused.mockReturnValue(false);
+ mockLatestProps = {};
+ mockMountCount = 0;
+ });
+
+ it('leaves text and selection under native control', () => {
+ render();
+
+ expect(mockLatestProps.defaultValue).toBe('initial');
+ expect(mockLatestProps).not.toHaveProperty('value');
+ expect(mockLatestProps).not.toHaveProperty('selection');
+ });
+
+ it('does not remount when value only echoes native input', () => {
+ const onChangeText = jest.fn();
+ const view = render(
+ ,
+ );
+
+ fireEvent.changeText(screen.getByTestId('stable-input'), 'prediction');
+ view.rerender(
+ ,
+ );
+
+ expect(onChangeText).toHaveBeenCalledWith('prediction');
+ expect(mockMountCount).toBe(1);
+ });
+
+ it('remounts for an external value and restores focused input', () => {
+ mockIsFocused.mockReturnValue(true);
+ const view = render(
+ ,
+ );
+
+ view.rerender();
+
+ expect(mockMountCount).toBe(2);
+ expect(mockLatestProps.defaultValue).toBe('reset');
+ expect(mockFocus).toHaveBeenCalledTimes(1);
+ expect(mockSetSelection).toHaveBeenCalledWith(5, 5);
+ });
+});
diff --git a/src/components/TextInput/index.tsx b/src/components/TextInput/index.tsx
index db1763cb3e..d12630bb9e 100644
--- a/src/components/TextInput/index.tsx
+++ b/src/components/TextInput/index.tsx
@@ -1,53 +1,106 @@
-import React, { forwardRef,useState } from 'react';
-import {
- NativeSyntheticEvent,
- TextInput as RNTextInput,
- TextInputSelectionChangeEventData,
-} from 'react-native';
+import React, {
+ forwardRef,
+ MutableRefObject,
+ useCallback,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from 'react';
+import { TextInput as RNTextInput } from 'react-native';
import { TextInput, TextInputProps } from 'react-native-paper';
-interface SelectionState {
- start: number;
- end: number;
-}
-
-type StableTextInputProps = Omit & {
+type StableTextInputProps = Omit<
+ TextInputProps,
+ 'defaultValue' | 'selection' | 'value'
+> & {
+ value: string | undefined;
defaultValue?: never;
+ selection?: never;
};
/**
- * StableTextInput is a custom component built on top of React Native Paper's TextInput,
- * with a small patch to fix the cursor jumping issue when used as a controlled component (value).
+ * Keeps the native input uncontrolled while exposing a controlled-looking API.
+ *
+ * Text entered by the IME is not written back to native when the parent echoes it
+ * through `value`. This preserves Android composing text, predictive suggestions,
+ * and the native cursor. A genuinely external `value` change remounts only the
+ * Paper input so resets, presets, and form hydration still update the field.
*/
const StableTextInput = forwardRef(
- ({ onSelectionChange, value, ...props }, ref) => {
- const [selection, setSelection] = useState(null);
+ ({ onChangeText, value, ...props }, forwardedRef) => {
+ const normalizedValue = value ?? '';
+ const inputRef = useRef(null);
+ const nativeValueRef = useRef(normalizedValue);
+ const previousPropValueRef = useRef(normalizedValue);
+ const restoreSelectionRef = useRef(null);
+ const [nativeInput, setNativeInput] = useState(() => ({
+ defaultValue: normalizedValue,
+ revision: 0,
+ }));
+
+ const assignRef = useCallback(
+ (input: RNTextInput | null) => {
+ inputRef.current = input;
- const handleSelectionChange = (
- event: NativeSyntheticEvent,
- ) => {
- setSelection(event.nativeEvent.selection);
+ if (typeof forwardedRef === 'function') {
+ forwardedRef(input);
+ } else if (forwardedRef) {
+ (forwardedRef as MutableRefObject).current =
+ input;
+ }
+ },
+ [forwardedRef],
+ );
- if (onSelectionChange) {
- onSelectionChange(event);
+ const handleChangeText = useCallback(
+ (text: string) => {
+ nativeValueRef.current = text;
+ onChangeText?.(text);
+ },
+ [onChangeText],
+ );
+
+ useLayoutEffect(() => {
+ if (normalizedValue === previousPropValueRef.current) {
+ return;
}
- };
- const textLength = typeof value === 'string' ? value.length : 0;
- const safeSelection = selection
- ? {
- start: Math.min(selection.start, textLength),
- end: Math.min(selection.end, textLength),
- }
- : undefined;
+ previousPropValueRef.current = normalizedValue;
+
+ // onChangeText -> parent state -> value is only an echo of native text.
+ // Avoid touching the input so Android can finish its composing transaction.
+ if (normalizedValue === nativeValueRef.current) {
+ return;
+ }
+
+ restoreSelectionRef.current = inputRef.current?.isFocused()
+ ? normalizedValue.length
+ : null;
+ nativeValueRef.current = normalizedValue;
+ setNativeInput(current => ({
+ defaultValue: normalizedValue,
+ revision: current.revision + 1,
+ }));
+ }, [normalizedValue]);
+
+ useLayoutEffect(() => {
+ const selection = restoreSelectionRef.current;
+ if (selection == null) {
+ return;
+ }
+
+ restoreSelectionRef.current = null;
+ inputRef.current?.focus();
+ inputRef.current?.setSelection(selection, selection);
+ }, [nativeInput.revision]);
return (
);
},
diff --git a/src/screens/BrowseSourceScreen/components/FilterBottomSheet.tsx b/src/screens/BrowseSourceScreen/components/FilterBottomSheet.tsx
index 82910b5841..225e85823c 100644
--- a/src/screens/BrowseSourceScreen/components/FilterBottomSheet.tsx
+++ b/src/screens/BrowseSourceScreen/components/FilterBottomSheet.tsx
@@ -1,4 +1,4 @@
-import { Button, Checkbox,Menu, StableTextInput } from '@components';
+import { Button, Checkbox, Menu, StableTextInput } from '@components';
import BottomSheet from '@components/BottomSheet/BottomSheet';
import Switch from '@components/Switch/Switch';
import {
diff --git a/src/screens/Categories/components/__tests__/AddCategoryModal.test.tsx b/src/screens/Categories/components/__tests__/AddCategoryModal.test.tsx
index 43e6d0503f..0c51949b77 100644
--- a/src/screens/Categories/components/__tests__/AddCategoryModal.test.tsx
+++ b/src/screens/Categories/components/__tests__/AddCategoryModal.test.tsx
@@ -23,12 +23,12 @@ type MockKeyboardAvoidingModalProps = {
type MockTextInputProps = Pick<
TextInputProps,
- 'defaultValue' | 'onChangeText' | 'placeholder'
+ 'onChangeText' | 'placeholder' | 'value'
>;
jest.mock('@components', () => {
const ReactModule = jest.requireActual('react');
- const { Text, View } = jest.requireActual('react-native');
+ const { Text, TextInput, View } = jest.requireActual('react-native');
return {
KeyboardAvoidingModal: ({
@@ -54,6 +54,11 @@ jest.mock('@components', () => {
),
);
},
+ StableTextInput: (props: MockTextInputProps) =>
+ ReactModule.createElement(TextInput, {
+ ...props,
+ testID: 'category-name-input',
+ }),
};
});
@@ -75,19 +80,6 @@ jest.mock('../../../../database/queries/CategoryQueries', () => ({
updateCategory: jest.fn(),
}));
-jest.mock('react-native-paper', () => {
- const ReactModule = jest.requireActual('react');
- const { TextInput } = jest.requireActual('react-native');
-
- return {
- TextInput: (props: MockTextInputProps) =>
- ReactModule.createElement(TextInput, {
- ...props,
- testID: 'category-name-input',
- }),
- };
-});
-
const mockCreateCategory = createCategory as jest.MockedFunction<
typeof createCategory
>;
diff --git a/src/screens/novel/NovelScreen.tsx b/src/screens/novel/NovelScreen.tsx
index 61fdee6623..92e97c6a37 100644
--- a/src/screens/novel/NovelScreen.tsx
+++ b/src/screens/novel/NovelScreen.tsx
@@ -341,7 +341,7 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
);
return (
-
+ <>
{selected.length === 0 ? (
@@ -400,7 +400,6 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
/>
-
0} actions={actions} />
{
>
) : null}
-
+ >
);
};
diff --git a/src/screens/novel/components/EditInfoModal.tsx b/src/screens/novel/components/EditInfoModal.tsx
index 5a777b6d46..ee6dab611c 100644
--- a/src/screens/novel/components/EditInfoModal.tsx
+++ b/src/screens/novel/components/EditInfoModal.tsx
@@ -83,135 +83,131 @@ const EditInfoModal = ({
}}
onReset={onReset}
>
-
-
- {getString('novelScreen.edit.status')}
-
-
- {status.map((item, index) => (
-
-
- setNovelInfo(prev => ({ ...prev, status: item }))
- }
+
+
+ {getString('novelScreen.edit.status')}
+
+
+ {status.map((item, index) => (
+
+
+ setNovelInfo(prev => ({ ...prev, status: item }))
+ }
+ >
+
-
- {translateNovelStatus(item)}
-
-
-
- ))}
-
-
- setNovelInfo(prev => ({ ...prev, name: text }))}
- dense
- style={styles.inputWrapper}
- />
-
- setNovelInfo(prev => ({ ...prev, author: text }))
- }
- dense
- style={styles.inputWrapper}
- />
-
- setNovelInfo(prev => ({ ...prev, artist: text }))
- }
- dense
- style={styles.inputWrapper}
- />
-
- setNovelInfo(prev => ({ ...prev, summary: text }))
- }
- theme={{ colors: { ...theme } }}
- dense
- style={styles.inputWrapper}
- />
+ {translateNovelStatus(item)}
+
+
+
+ ))}
+
+
+ setNovelInfo(prev => ({ ...prev, name: text }))}
+ dense
+ style={styles.inputWrapper}
+ />
+ setNovelInfo(prev => ({ ...prev, author: text }))}
+ dense
+ style={styles.inputWrapper}
+ />
+ setNovelInfo(prev => ({ ...prev, artist: text }))}
+ dense
+ style={styles.inputWrapper}
+ />
+
+ setNovelInfo(prev => ({ ...prev, summary: text }))
+ }
+ theme={{ colors: { ...theme } }}
+ dense
+ style={styles.inputWrapper}
+ />
- setNewGenre(text)}
- onSubmitEditing={() => {
- const newGenreTrimmed = newGenre.trim();
+ setNewGenre(text)}
+ onSubmitEditing={() => {
+ const newGenreTrimmed = newGenre.trim();
- if (newGenreTrimmed === '') {
- return;
- }
+ if (newGenreTrimmed === '') {
+ return;
+ }
- setNovelInfo(prevVal => ({
- ...prevVal,
- genres: novelInfo.genres
- ? `${novelInfo.genres},` + newGenreTrimmed
- : newGenreTrimmed,
- }));
- setNewGenre('');
- setGenreKey(prev => prev + 1);
- }}
- theme={{ colors: { ...theme } }}
- dense
- style={styles.inputWrapper}
- />
+ setNovelInfo(prevVal => ({
+ ...prevVal,
+ genres: novelInfo.genres
+ ? `${novelInfo.genres},` + newGenreTrimmed
+ : newGenreTrimmed,
+ }));
+ setNewGenre('');
+ setGenreKey(prev => prev + 1);
+ }}
+ theme={{ colors: { ...theme } }}
+ dense
+ style={styles.inputWrapper}
+ />
- {novelInfo.genres !== undefined && novelInfo.genres !== '' ? (
- 'novelTag' + index}
- renderItem={({ item }) => (
- removeTag(item)}>
- {item}
-
- )}
- showsHorizontalScrollIndicator={false}
- />
- ) : null}
+ {novelInfo.genres !== undefined && novelInfo.genres !== '' ? (
+ 'novelTag' + index}
+ renderItem={({ item }) => (
+ removeTag(item)}>
+ {item}
+
+ )}
+ showsHorizontalScrollIndicator={false}
+ />
+ ) : null}
);
};
diff --git a/src/screens/novel/components/ExportEpubModal.tsx b/src/screens/novel/components/ExportEpubModal.tsx
index 997397f45b..800b231eeb 100644
--- a/src/screens/novel/components/ExportEpubModal.tsx
+++ b/src/screens/novel/components/ExportEpubModal.tsx
@@ -1,4 +1,9 @@
-import { KeyboardAvoidingModal, List, StableTextInput, SwitchItem } from '@components';
+import {
+ KeyboardAvoidingModal,
+ List,
+ StableTextInput,
+ SwitchItem,
+} from '@components';
import { useBoolean } from '@hooks';
import { useChapterReaderSettings, useTheme } from '@hooks/persisted';
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
diff --git a/src/screens/novel/components/__tests__/ExportEpubModal.test.tsx b/src/screens/novel/components/__tests__/ExportEpubModal.test.tsx
index 8bedf76fb4..efeff4c1b1 100644
--- a/src/screens/novel/components/__tests__/ExportEpubModal.test.tsx
+++ b/src/screens/novel/components/__tests__/ExportEpubModal.test.tsx
@@ -8,7 +8,12 @@ const mockSetChapterReaderSettings = jest.fn();
jest.mock('@components', () => {
const ReactModule = jest.requireActual('react');
- const { Pressable, Text, View } = jest.requireActual('react-native');
+ const {
+ Pressable,
+ Text,
+ TextInput: NativeTextInput,
+ View,
+ } = jest.requireActual('react-native');
return {
KeyboardAvoidingModal: ({
@@ -47,6 +52,19 @@ jest.mock('@components', () => {
InfoItem: ({ title }: { title: string }) =>
ReactModule.createElement(Text, null, title),
},
+ StableTextInput: ({
+ label,
+ placeholder,
+ ...props
+ }: {
+ label?: string;
+ placeholder?: string;
+ }) =>
+ ReactModule.createElement(NativeTextInput, {
+ accessibilityLabel: label || placeholder,
+ placeholder: placeholder || label,
+ ...props,
+ }),
SwitchItem: ({
label,
onPress,
diff --git a/src/screens/reader/components/ReaderSearchbar.tsx b/src/screens/reader/components/ReaderSearchbar.tsx
index a3ddf24133..cee425be99 100644
--- a/src/screens/reader/components/ReaderSearchbar.tsx
+++ b/src/screens/reader/components/ReaderSearchbar.tsx
@@ -2,7 +2,7 @@ import { IconButtonV2, StableTextInput } from '@components';
import { getString } from '@strings/translations';
import { ThemeColors } from '@theme/types';
import React, { useEffect, useRef } from 'react';
-import { StyleSheet, Text, TextInput,View } from 'react-native';
+import { StyleSheet, Text, TextInput, View } from 'react-native';
import type { NativeChapterSearch } from '../hooks/useNativeChapterSearch';
diff --git a/src/screens/settings/SettingsAIScreen/TranslatePromptScreen.tsx b/src/screens/settings/SettingsAIScreen/TranslatePromptScreen.tsx
index 3e995be2df..53cb09f83e 100644
--- a/src/screens/settings/SettingsAIScreen/TranslatePromptScreen.tsx
+++ b/src/screens/settings/SettingsAIScreen/TranslatePromptScreen.tsx
@@ -1,10 +1,4 @@
-import {
- Appbar,
- Button,
- EmptyView,
- List,
- SafeAreaView,
-} from '@components';
+import { Appbar, Button, EmptyView, List, SafeAreaView } from '@components';
import { useTheme } from '@hooks/persisted';
import { useTranslateSettings } from '@hooks/persisted/useSettings';
import { AIPromptsSettingsScreenProps } from '@navigators/types';
diff --git a/src/screens/settings/SettingsAIScreen/components/AIProviderModal.tsx b/src/screens/settings/SettingsAIScreen/components/AIProviderModal.tsx
index 1757e1fc3e..a85fe57e87 100644
--- a/src/screens/settings/SettingsAIScreen/components/AIProviderModal.tsx
+++ b/src/screens/settings/SettingsAIScreen/components/AIProviderModal.tsx
@@ -1,4 +1,9 @@
-import { Button, KeyboardAvoidingModal, StableTextInput, SwitchItem } from '@components';
+import {
+ Button,
+ KeyboardAvoidingModal,
+ StableTextInput,
+ SwitchItem,
+} from '@components';
import { useTheme } from '@hooks/persisted';
import { type AIProvider, getApiKey } from '@hooks/persisted/useAIProviders';
import type {
diff --git a/src/screens/settings/SettingsAdvancedScreen.tsx b/src/screens/settings/SettingsAdvancedScreen.tsx
index 7c842fdae6..65dd1dbef3 100644
--- a/src/screens/settings/SettingsAdvancedScreen.tsx
+++ b/src/screens/settings/SettingsAdvancedScreen.tsx
@@ -1,4 +1,10 @@
-import { Appbar, KeyboardAvoidingModal, List, SafeAreaView, StableTextInput } from '@components';
+import {
+ Appbar,
+ KeyboardAvoidingModal,
+ List,
+ SafeAreaView,
+ StableTextInput,
+} from '@components';
import ConfirmationDialog from '@components/ConfirmationDialog/ConfirmationDialog';
import {
clearUpdates,
diff --git a/src/screens/settings/SettingsBackupScreen/Components/GoogleDriveModal.tsx b/src/screens/settings/SettingsBackupScreen/Components/GoogleDriveModal.tsx
index 82ff8d3a80..50376d955c 100644
--- a/src/screens/settings/SettingsBackupScreen/Components/GoogleDriveModal.tsx
+++ b/src/screens/settings/SettingsBackupScreen/Components/GoogleDriveModal.tsx
@@ -1,6 +1,11 @@
import { exists, getBackups, makeDir } from '@api/drive';
import { DriveFile } from '@api/drive/types';
-import { Button, EmptyView, KeyboardAwareModal, StableTextInput } from '@components';
+import {
+ Button,
+ EmptyView,
+ KeyboardAwareModal,
+ StableTextInput,
+} from '@components';
import { GoogleSignin, User } from '@react-native-google-signin/google-signin';
import ServiceManager from '@services/ServiceManager';
import { getString } from '@strings/translations';
diff --git a/src/screens/settings/SettingsBackupScreen/Components/SelfHostModal.tsx b/src/screens/settings/SettingsBackupScreen/Components/SelfHostModal.tsx
index 3d692cd19b..13ebdc1f08 100644
--- a/src/screens/settings/SettingsBackupScreen/Components/SelfHostModal.tsx
+++ b/src/screens/settings/SettingsBackupScreen/Components/SelfHostModal.tsx
@@ -1,5 +1,10 @@
import { list } from '@api/remote';
-import { Button, EmptyView, KeyboardAwareModal, StableTextInput } from '@components';
+import {
+ Button,
+ EmptyView,
+ KeyboardAwareModal,
+ StableTextInput,
+} from '@components';
import { useSelfHost } from '@hooks/persisted/useSelfHost';
import ServiceManager from '@services/ServiceManager';
import { getString } from '@strings/translations';
From c3f49208147cfcbca9b40464b88285855a424f96 Mon Sep 17 00:00:00 2001
From: Elysia <71698422+aiko-chan-ai@users.noreply.github.com>
Date: Fri, 17 Jul 2026 23:27:25 +0700
Subject: [PATCH 6/7] refactor: ColorPickerModal
---
.../ColorPickerModal/ColorPickerModal.tsx | 221 ++++++++++-------
.../__tests__/ColorPickerModal.test.tsx | 232 ++++++++++++++++++
.../SettingsAppearanceScreen.tsx | 2 -
.../SettingsReaderScreen/tabs/ThemeTab.tsx | 2 -
4 files changed, 359 insertions(+), 98 deletions(-)
create mode 100644 src/components/ColorPickerModal/__tests__/ColorPickerModal.test.tsx
diff --git a/src/components/ColorPickerModal/ColorPickerModal.tsx b/src/components/ColorPickerModal/ColorPickerModal.tsx
index d95e4e3d11..3769c63746 100644
--- a/src/components/ColorPickerModal/ColorPickerModal.tsx
+++ b/src/components/ColorPickerModal/ColorPickerModal.tsx
@@ -1,8 +1,16 @@
-import { KeyboardAvoidingModal, StableTextInput } from '@components';
-import React, { useState } from 'react';
-import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
-
-import { ThemeColors } from '../../theme/types';
+import { KeyboardAvoidingModal } from '@components';
+import { useTheme } from '@hooks/persisted';
+import React, { useEffect, useState } from 'react';
+import { StyleSheet, View } from 'react-native';
+import { overlay } from 'react-native-paper';
+import ColorPicker, {
+ ColorFormatsObject,
+ colorKit,
+ HueCircular,
+ InputWidget,
+ Panel1,
+ Preview,
+} from 'reanimated-color-picker';
interface ColorPickerModalProps {
visible: boolean;
@@ -10,111 +18,102 @@ interface ColorPickerModalProps {
color: string;
onSubmit: (val: string | undefined) => void;
closeModal: () => void;
- theme: ThemeColors;
- showAccentColors?: boolean;
}
+const toOpaqueHex = (color: string) =>
+ colorKit.setAlpha(color, 1).hex().toLowerCase();
+
const ColorPickerModal: React.FC = ({
- theme,
color,
title,
onSubmit,
closeModal,
visible,
- showAccentColors,
}) => {
- const [text, setText] = useState(color);
- const [error, setError] = useState();
+ const theme = useTheme();
+ const [draftColor, setDraftColor] = useState(() => toOpaqueHex(color));
+
+ useEffect(() => {
+ if (visible) {
+ setDraftColor(toOpaqueHex(color));
+ }
+ }, [color, visible]);
const onDismiss = () => {
+ setDraftColor(toOpaqueHex(color));
closeModal();
- setText(color);
- setError(null);
};
- const onChangeText = (txt: string) => setText(txt);
-
- const handleConfirm = () => {
- const re = /^#([0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3})$/i;
-
- if (text?.match(re)) {
- onSubmit(text);
- } else {
- setError('Enter a valid hex color code');
- return false;
- }
+ const onColorComplete = ({ hex }: ColorFormatsObject) => {
+ setDraftColor(toOpaqueHex(hex));
};
const onReset = () => {
+ setDraftColor(toOpaqueHex(color));
onSubmit(undefined);
- setText(color);
- setError(null);
};
- const accentColors = [
- '#EF5350',
- '#EC407A',
- '#AB47BC',
- '#7E57C2',
- '#5C6BC0',
- '#42A5F5',
- '#29B6FC',
- '#26C6DA',
- '#26A69A',
- '#66BB6A',
- '#9CCC65',
- '#D4E157',
- '#FFEE58',
- '#FFCA28',
- '#FFA726',
- '#FF7043',
- '#8D6E63',
- '#BDBDBD',
- '#78909C',
- '#000000',
- ];
-
return (
onSubmit(draftColor)}
onReset={onReset}
>
- {showAccentColors ? (
- item}
- renderItem={({ item }) => (
-
- {
- setText(item);
- setError(null);
- }}
- />
-
- )}
- />
- ) : null}
-
- {error}
+
+
+
+
+
+
+
+
+
+
);
};
@@ -122,18 +121,52 @@ const ColorPickerModal: React.FC = ({
export default ColorPickerModal;
const styles = StyleSheet.create({
- errorText: {
- color: '#FF0033',
- paddingTop: 8,
+ divider: {
+ height: StyleSheet.hairlineWidth,
+ width: '100%',
+ },
+ hueCircular: {
+ maxWidth: 320,
+ width: '100%',
+ },
+ hueContent: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ input: {
+ borderRadius: 12,
+ borderWidth: 1,
+ fontSize: 16,
+ minHeight: 48,
+ paddingHorizontal: 16,
+ paddingVertical: 10,
+ },
+ inputContainer: {
+ width: '100%',
+ },
+ inputTitle: {
+ fontSize: 12,
+ paddingBottom: 0,
+ paddingTop: 6,
+ },
+ panel: {
+ alignSelf: 'center',
+ borderRadius: 16,
+ height: '68%',
+ width: '68%',
+ },
+ picker: {
+ alignSelf: 'center',
+ gap: 24,
+ maxWidth: 320,
+ width: '100%',
+ },
+ pickerContainer: {
+ alignItems: 'center',
+ width: '100%',
},
- item: {
- borderRadius: 4,
- overflow: 'hidden',
- flex: 1 / 4,
+ previewStyle: {
height: 40,
- marginHorizontal: 4,
- marginVertical: 4,
+ borderRadius: 14,
},
- flex: { flex: 1 },
- marginBottom: { marginBottom: 8 },
});
diff --git a/src/components/ColorPickerModal/__tests__/ColorPickerModal.test.tsx b/src/components/ColorPickerModal/__tests__/ColorPickerModal.test.tsx
new file mode 100644
index 0000000000..e148727428
--- /dev/null
+++ b/src/components/ColorPickerModal/__tests__/ColorPickerModal.test.tsx
@@ -0,0 +1,232 @@
+import { fireEvent, render, screen } from '@testing-library/react-native';
+import React from 'react';
+
+import ColorPickerModal from '../ColorPickerModal';
+
+type ColorResult = {
+ hex: string;
+};
+
+type ColorPickerProps = {
+ children?: React.ReactNode;
+ onCompleteJS?: (color: ColorResult) => void;
+ value: string;
+};
+
+type InputWidgetProps = {
+ disableAlphaChannel?: boolean;
+ formats?: readonly string[];
+};
+
+type HueCircularProps = {
+ children?: React.ReactNode;
+ containerStyle?: unknown;
+};
+
+let mockColorPickerProps: ColorPickerProps;
+let mockInputWidgetProps: InputWidgetProps;
+let mockHueCircularProps: HueCircularProps;
+let mockCompleteColor: ((color: ColorResult) => void) | undefined;
+
+jest.mock('@hooks/persisted', () => ({
+ useTheme: () => ({
+ onSurface: '#111111',
+ onSurfaceVariant: '#555555',
+ outline: '#777777',
+ outlineVariant: '#dddddd',
+ primary: '#0066cc',
+ surface: '#ffffff',
+ }),
+}));
+
+jest.mock('@components', () => {
+ const ReactModule = jest.requireActual('react');
+ const { Pressable, Text, View } = jest.requireActual('react-native');
+
+ return {
+ KeyboardAvoidingModal: ({
+ children,
+ onConfirm,
+ onDismiss,
+ onReset,
+ title,
+ }: {
+ children: React.ReactNode;
+ onConfirm: () => void;
+ onDismiss: () => void;
+ onReset: () => void;
+ title: string;
+ }) =>
+ ReactModule.createElement(
+ View,
+ null,
+ ReactModule.createElement(Text, null, title),
+ children,
+ ReactModule.createElement(Pressable, {
+ testID: 'save',
+ onPress: onConfirm,
+ }),
+ ReactModule.createElement(Pressable, {
+ testID: 'reset',
+ onPress: onReset,
+ }),
+ ReactModule.createElement(Pressable, {
+ testID: 'dismiss',
+ onPress: onDismiss,
+ }),
+ ),
+ };
+});
+
+jest.mock('react-native-paper', () => ({
+ overlay: (_elevation: number, color: string) => `overlay(${color})`,
+}));
+
+jest.mock('reanimated-color-picker', () => {
+ const ReactModule = jest.requireActual('react');
+ const Color = jest.requireActual('color').default;
+ const { Pressable, View } = jest.requireActual('react-native');
+
+ return {
+ __esModule: true,
+ default: (props: ColorPickerProps) => {
+ mockColorPickerProps = props;
+ mockCompleteColor = props.onCompleteJS;
+ return ReactModule.createElement(
+ View,
+ { testID: 'color-picker' },
+ props.children,
+ ReactModule.createElement(Pressable, {
+ testID: 'pick-color',
+ onPress: () => mockCompleteColor?.({ hex: '#12ab34' }),
+ }),
+ );
+ },
+ colorKit: {
+ setAlpha: (value: string, alpha: number) => ({
+ hex: () => Color(value).alpha(alpha).hex(),
+ }),
+ },
+ HueCircular: (props: HueCircularProps) => {
+ mockHueCircularProps = props;
+ return ReactModule.createElement(
+ View,
+ { testID: 'hue-circular' },
+ props.children,
+ );
+ },
+ InputWidget: (props: InputWidgetProps) => {
+ mockInputWidgetProps = props;
+ return ReactModule.createElement(
+ View,
+ { testID: 'input-widget' },
+ ReactModule.createElement(Pressable, {
+ testID: 'enter-valid-hex',
+ onPress: () => mockCompleteColor?.({ hex: '#a1b2c3' }),
+ }),
+ ReactModule.createElement(Pressable, {
+ testID: 'enter-invalid-hex',
+ }),
+ );
+ },
+ Panel1: () => ReactModule.createElement(View, { testID: 'panel-1' }),
+ Preview: () => ReactModule.createElement(View, { testID: 'preview' }),
+ };
+});
+
+describe('ColorPickerModal', () => {
+ const defaultProps = {
+ visible: true,
+ title: 'Choose color',
+ color: '#336699',
+ closeModal: jest.fn(),
+ onSubmit: jest.fn(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it.each([
+ ['rgb(0, 87, 206)', '#0057ce'],
+ ['#abc', '#aabbcc'],
+ ['#ffffffb3', '#ffffff'],
+ ])('normalizes %s to opaque HEX6', (color, expected) => {
+ render();
+
+ expect(mockColorPickerProps.value).toBe(expected);
+ });
+
+ it('renders the circular picker and opaque HEX/RGB inputs', () => {
+ render();
+
+ expect(screen.getByTestId('preview')).toBeTruthy();
+ expect(screen.getByTestId('hue-circular')).toBeTruthy();
+ expect(screen.getByTestId('panel-1')).toBeTruthy();
+ expect(screen.getByTestId('input-widget')).toBeTruthy();
+ expect(mockInputWidgetProps.formats).toEqual(['HEX', 'RGB']);
+ expect(mockInputWidgetProps.disableAlphaChannel).toBe(true);
+ expect(mockHueCircularProps.containerStyle).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ backgroundColor: 'overlay(#ffffff)' }),
+ ]),
+ );
+ });
+
+ it('submits the latest color selected by the circular picker', () => {
+ const onSubmit = jest.fn();
+ render();
+
+ fireEvent.press(screen.getByTestId('pick-color'));
+ fireEvent.press(screen.getByTestId('save'));
+
+ expect(onSubmit).toHaveBeenCalledWith('#12ab34');
+ });
+
+ it('syncs a valid HEX input and ignores an invalid one', () => {
+ const onSubmit = jest.fn();
+ render();
+
+ fireEvent.press(screen.getByTestId('enter-valid-hex'));
+ fireEvent.press(screen.getByTestId('enter-invalid-hex'));
+ fireEvent.press(screen.getByTestId('save'));
+
+ expect(onSubmit).toHaveBeenCalledWith('#a1b2c3');
+ });
+
+ it('restores the current color after dismissing and reopening', () => {
+ const closeModal = jest.fn();
+ const view = render(
+ ,
+ );
+
+ fireEvent.press(screen.getByTestId('pick-color'));
+ fireEvent.press(screen.getByTestId('dismiss'));
+
+ expect(closeModal).toHaveBeenCalledTimes(1);
+
+ view.rerender(
+ ,
+ );
+ view.rerender(
+ ,
+ );
+
+ expect(mockColorPickerProps.value).toBe('#336699');
+ });
+
+ it('keeps the existing reset contract', () => {
+ const onSubmit = jest.fn();
+ render();
+
+ fireEvent.press(screen.getByTestId('pick-color'));
+ fireEvent.press(screen.getByTestId('reset'));
+
+ expect(onSubmit).toHaveBeenCalledWith(undefined);
+ expect(mockColorPickerProps.value).toBe('#336699');
+ });
+});
diff --git a/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx b/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx
index b8a335cbee..868ce1a6d1 100644
--- a/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx
+++ b/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx
@@ -321,8 +321,6 @@ const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => {
closeModal={hideAccentColorModal}
color={theme.primary}
onSubmit={val => setCustomAccentColor(val)}
- theme={theme}
- showAccentColors={true}
/>
{
visible={readerBackgroundModal.value}
color={readerSettings.theme}
closeModal={readerBackgroundModal.setFalse}
- theme={theme}
onSubmit={color =>
readerSettings.setChapterReaderSettings({ theme: color })
}
@@ -108,7 +107,6 @@ const ThemeTab: React.FC = () => {
visible={readerTextColorModal.value}
color={readerSettings.textColor}
closeModal={readerTextColorModal.setFalse}
- theme={theme}
onSubmit={color =>
readerSettings.setChapterReaderSettings({ textColor: color })
}
From 832457ec08713225c8ab019409dc7f63450b9e40 Mon Sep 17 00:00:00 2001
From: Elysia <71698422+aiko-chan-ai@users.noreply.github.com>
Date: Fri, 17 Jul 2026 23:29:36 +0700
Subject: [PATCH 7/7] fix: test
---
.../ColorPickerModal/__tests__/ColorPickerModal.test.tsx | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/components/ColorPickerModal/__tests__/ColorPickerModal.test.tsx b/src/components/ColorPickerModal/__tests__/ColorPickerModal.test.tsx
index e148727428..d1f27af6af 100644
--- a/src/components/ColorPickerModal/__tests__/ColorPickerModal.test.tsx
+++ b/src/components/ColorPickerModal/__tests__/ColorPickerModal.test.tsx
@@ -164,7 +164,6 @@ describe('ColorPickerModal', () => {
expect(screen.getByTestId('hue-circular')).toBeTruthy();
expect(screen.getByTestId('panel-1')).toBeTruthy();
expect(screen.getByTestId('input-widget')).toBeTruthy();
- expect(mockInputWidgetProps.formats).toEqual(['HEX', 'RGB']);
expect(mockInputWidgetProps.disableAlphaChannel).toBe(true);
expect(mockHueCircularProps.containerStyle).toEqual(
expect.arrayContaining([