Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export default ({ config }: ConfigContext): ExpoConfig =>
owner: 'eten-genesis',
name: getAppName(appVariant),
slug: 'langquest',
version: '2.0.10',
version: '2.0.11',
orientation: 'portrait',
icon: iconLight,
scheme: getScheme(appVariant),
Expand Down
5 changes: 5 additions & 0 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ export default function RootLayout() {
});

useEffect(() => {
// async function init() {
// await tagService.preloadTagsIntoCache();
// }
// void init();

if (Platform.OS === 'web') return;
console.log('[_layout] Setting up deep link handler');

Expand Down
37 changes: 37 additions & 0 deletions components/AddVerseLabelButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { PlusCircleIcon } from 'lucide-react-native';
import React from 'react';
import { Pressable, View } from 'react-native';
import { Icon } from './ui/icon';
import { Text } from './ui/text';

interface AddVerseLabelButtonProps {
onPress: () => void;
disabled?: boolean;
className?: string;
}

export function AddVerseLabelButton({
onPress,
disabled = false,
className = ''
}: AddVerseLabelButtonProps) {
return (
<View className={`-mb-3 w-full flex-row items-center ${className}`}>
{/* <View className="h-px flex-1 bg-primary/10" /> */}
<View className="h-px flex-1" />
<Pressable
onPress={onPress}
disabled={disabled}
className={`mx-1.5 flex-row items-center gap-1.5 rounded-full border border-dashed border-primary/30 px-2.5 active:bg-primary/10 ${
disabled ? 'opacity-40' : ''
}`}
>
<Icon as={PlusCircleIcon} size={12} className="text-primary/70" />
<Text className="text-[10.5px] font-semibold text-primary/70">
Add verse
</Text>
</Pressable>
{/* <View className="h-px flex-1 bg-primary/10" /> */}
</View>
);
}
136 changes: 91 additions & 45 deletions components/ArrayInsertionWheel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,47 +12,66 @@ export interface ArrayInsertionWheelHandle {
scrollItemToTop: (index: number, animated?: boolean) => void;
}

interface ArrayInsertionWheelProps {
children: React.ReactNode[];
interface ArrayInsertionWheelPropsBase {
value: number; // 0..N insertion boundary
onChange?: (index: number) => void;
rowHeight: number;
className?: string;
topInset?: number; // unused in native wheel, kept for API parity
bottomInset?: number; // unused in native wheel, kept for API parity
boundaryComponent?: React.ReactNode;
}

// API 1: Eager rendering with children (backward compatible)
interface ArrayInsertionWheelPropsEager extends ArrayInsertionWheelPropsBase {
children: React.ReactNode[];
data?: never;
renderItem?: never;
}

function ArrayInsertionWheelInternal(
{
children,
// API 2: Lazy rendering with data + renderItem (optimized)
interface ArrayInsertionWheelPropsLazy<T> extends ArrayInsertionWheelPropsBase {
children?: never;
data: T[];
renderItem: (item: T, index: number) => React.ReactElement;
}

type ArrayInsertionWheelProps<T = unknown> =
| ArrayInsertionWheelPropsEager
| ArrayInsertionWheelPropsLazy<T>;

function ArrayInsertionWheelInternal<T>(
props: ArrayInsertionWheelProps<T>,
ref: React.Ref<ArrayInsertionWheelHandle>
) {
const {
value,
onChange,
rowHeight,
className,
topInset = 0,
bottomInset = 0
}: ArrayInsertionWheelProps,
ref: React.Ref<ArrayInsertionWheelHandle>
) {
const itemCount = children.length + 1; // extra end boundary
bottomInset = 0,
boundaryComponent
} = props;

// Determine which API is being used
const isLazyMode = 'data' in props && props.data !== undefined;

// Calculate item count based on mode
let itemCount: number;
if (isLazyMode) {
itemCount = props.data.length + 1;
} else {
// Eager mode - children is guaranteed by type
itemCount = props.children.length + 1;
}

const clampedValue = Math.max(0, Math.min(itemCount - 1, value));

// Stabilize clampedValue to prevent unnecessary WheelPicker updates
const prevClampedRef = React.useRef(clampedValue);
const stableClampedValue = React.useMemo(() => {
if (prevClampedRef.current !== clampedValue) {
console.log(
'📊 Wheel value changed:',
prevClampedRef.current,
'→',
clampedValue,
'| itemCount:',
itemCount
);
prevClampedRef.current = clampedValue;
}
return clampedValue;
}, [clampedValue, itemCount]);
}, [clampedValue]);

// Debug logging to trace clamping
React.useEffect(() => {
Expand All @@ -67,7 +86,7 @@ function ArrayInsertionWheelInternal(
}
}, [value, clampedValue, itemCount]);

const data = React.useMemo<PickerItem<number>[]>(
const pickerData = React.useMemo<PickerItem<number>[]>(
() => Array.from({ length: itemCount }, (_, i) => ({ value: i })),
[itemCount]
);
Expand Down Expand Up @@ -116,22 +135,50 @@ function ArrayInsertionWheelInternal(
[stableClampedValue, itemCount, onChange]
);

const renderItem = React.useCallback(
({ item }: { item: PickerItem<number> }) => {
const renderItemInternal = React.useCallback(
({ item }: { item: PickerItem<number> }): React.ReactElement => {
const i = item.value;

// Calculate data length based on mode
let dataLength: number;
if (isLazyMode) {
dataLength = props.data.length;
} else {
dataLength = props.children.length;
}

// Render actual items (not the final boundary)
if (i < children.length) {
return (
<View style={{ height: rowHeight, justifyContent: 'center' }}>
{children[i]}
</View>
);
if (i < dataLength) {
if (isLazyMode) {
// Lazy mode: call renderItem with data item
const dataItem = props.data[i];
if (!dataItem) {
// Defensive: should never happen, but TypeScript needs this
return <View style={{ height: rowHeight }} />;
}
return (
<View style={{ height: rowHeight, justifyContent: 'center' }}>
{props.renderItem(dataItem, i)}
</View>
);
} else {
// Eager mode: use pre-created children
const child = props.children[i];
return (
<View style={{ height: rowHeight, justifyContent: 'center' }}>
{child}
</View>
);
}
}

// Final boundary (i === children.length)
// Final boundary (i === dataLength)
// When empty (0 items), this is position 0 - the only insertion point
// When non-empty, this is position N - insert after all items
if (boundaryComponent) {
return <>{boundaryComponent}</>;
}

return (
<View
style={{ height: rowHeight }}
Expand Down Expand Up @@ -160,7 +207,7 @@ function ArrayInsertionWheelInternal(
</View>
);
},
[children, rowHeight]
[isLazyMode, props, rowHeight, boundaryComponent]
);

return (
Expand All @@ -174,15 +221,15 @@ function ArrayInsertionWheelInternal(
onLayout={onContainerLayout}
>
<WheelPicker
data={data}
data={pickerData}
value={stableClampedValue}
itemHeight={rowHeight}
visibleItemCount={visibleCount}
// Fire only on final change to avoid thrashing parent state during scroll
onValueChanged={({ item }) => onChange?.(item.value)}
// Constrain height to an exact multiple of rowHeight so overlay aligns
style={{ height: wheelHeight }}
renderItem={renderItem}
renderItem={renderItemInternal}
renderItemContainer={({ key, ...props }) => (
<ArrayInsertionWheelContainer key={key} {...props} />
)}
Expand All @@ -209,13 +256,12 @@ function ArrayInsertionWheelInternal(
);
}

export default React.forwardRef<
ArrayInsertionWheelHandle,
ArrayInsertionWheelProps
const ArrayInsertionWheel = React.forwardRef(ArrayInsertionWheelInternal) as <
T = unknown
>(
ArrayInsertionWheelInternal as unknown as (
props: ArrayInsertionWheelProps & {
ref?: React.Ref<ArrayInsertionWheelHandle>;
}
) => React.ReactElement
);
props: ArrayInsertionWheelProps<T> & {
ref?: React.Ref<ArrayInsertionWheelHandle>;
}
) => React.ReactElement;

export default ArrayInsertionWheel;
122 changes: 122 additions & 0 deletions components/AssetsDeletionDrawer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { Button } from '@/components/ui/button';
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle
} from '@/components/ui/drawer';
import { Input } from '@/components/ui/input';
import { Text } from '@/components/ui/text';
import { useLocalization } from '@/hooks/useLocalization';
import { AlertTriangleIcon } from 'lucide-react-native';
import React from 'react';
import { View } from 'react-native';
import { Icon } from './ui/icon';

interface AssetsDeletionDrawerProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void | Promise<void>;
title: string;
description: string;
confirmationString: string; // String that user must type to confirm deletion
}

export const AssetsDeletionDrawer: React.FC<AssetsDeletionDrawerProps> = ({
isOpen,
onClose,
onConfirm,
title,
description,
confirmationString
}) => {
const { t } = useLocalization();
const [inputValue, setInputValue] = React.useState('');
const [isExecuting, setIsExecuting] = React.useState(false);

// Reset input when drawer opens
React.useEffect(() => {
if (isOpen) {
setInputValue('');
setIsExecuting(false);
}
}, [isOpen]);

const handleConfirm = async () => {
if (inputValue !== confirmationString || isExecuting) return;

setIsExecuting(true);
try {
await onConfirm();
onClose();
} catch (error) {
console.error('Error executing deletion:', error);
} finally {
setIsExecuting(false);
}
};

const isButtonDisabled = inputValue !== confirmationString || isExecuting;

return (
<Drawer open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DrawerContent>
<DrawerHeader className="items-center">
<View className="mb-4 rounded-full bg-destructive/10 p-4">
<Icon
as={AlertTriangleIcon}
size={32}
className="text-destructive"
/>
</View>
<DrawerTitle className="text-center text-xl">{title}</DrawerTitle>
<DrawerDescription className="text-center">
{description}
</DrawerDescription>
</DrawerHeader>

<View className="px-4 pb-4">
<Text className="mb-2 text-sm text-muted-foreground">
{t('typeToConfirm').replace('{text}', `"${confirmationString}"`)}
</Text>
<Input
value={inputValue}
onChangeText={setInputValue}
placeholder={confirmationString}
autoCapitalize="none"
autoCorrect={false}
editable={!isExecuting}
/>
</View>

<DrawerFooter className="gap-2">
<Button
variant="destructive"
onPress={handleConfirm}
disabled={isButtonDisabled}
className={isButtonDisabled ? 'opacity-50' : ''}
>
{isExecuting ? (
<Text className="font-semibold text-destructive-foreground">
{t('deleting')}
</Text>
) : (
<Text className="font-semibold text-destructive-foreground">
{t('confirmDeletion')}
</Text>
)}
</Button>

<DrawerClose asChild>
<Button variant="outline" disabled={isExecuting}>
<Text className="font-semibold">{t('cancel')}</Text>
</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
};
Loading