Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,4 @@ GoogleService-Info.plist
# Documentation files (except README.md)
*.md
!README.md
!docs/**/*.md
58 changes: 58 additions & 0 deletions docs/form-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Form Value Caching

Client-side caching for reusable form fields (name, email, address, preferences) so users spend less time re-entering the same information across screens.

## Features

- **Persistent cache** — Values stored in AsyncStorage under `@teachlink/form-cache/v1`
- **Shared field keys** — One schema reused by profile edit, registration, and `MobileFormInput`
- **Suggestions** — Non-intrusive chip below focused inputs when a different cached value exists
- **Autofill on empty fields** — `useFormCache` fills only fields that are currently empty
- **Privacy control** — **Settings → Privacy → Clear Cached Form Data** removes all cached values
- **TTL** — Entries expire after **90 days**; expired keys are pruned on read/write

## Cached field keys

| Key | Typical use |
|-----|-------------|
| `fullName` | Profile, registration |
| `email` | Profile, registration |
| `bio` | Profile |
| `location` | Profile, address forms |
| `website` | Profile |
| `phone` | Contact forms |
| `addressLine1` / `addressLine2` | Shipping, billing |
| `city` / `state` / `postalCode` / `country` | Address blocks |
| `company` | Organization fields |

## Lifecycle

1. **Write** — On input blur (via `cacheKey` on `MobileFormInput`) or batch `cacheFormValues` after successful form save
2. **Read** — On form open (`applyPrefillToFields`) or input focus (suggestion chip)
3. **Expire** — `updatedAt` + `FORM_CACHE_TTL_MS`; pruned automatically
4. **Clear** — User action in settings or `clearFormCache()` in code/tests

## Implementation map

| Module | Role |
|--------|------|
| `src/services/formCache.ts` | Storage, TTL, suggestions |
| `src/hooks/useFormCache.ts` | React hook for multi-field forms |
| `src/components/mobile/MobileFormInput.tsx` | Per-field cache + suggestion UI |
| `src/components/mobile/MobileProfile.tsx` | Profile edit prefill + persist |
| `src/pages/mobile/MobileRegister.tsx` | Registration prefill + persist |
| `src/components/mobile/MobileSettings.tsx` | Clear cache control |

## Testing

```bash
npm run test -- tests/services/formCache.test.ts
npm run test -- tests/hooks/useFormCache.test.ts
```

Manual: edit profile → save → open registration or profile again → confirm suggestions/prefill; clear cache in settings → confirm fields no longer suggest.

## Related issues

- #401 — Form prefilling with cached values
- #5, #91, #132 — Broader UX / forms / persistence workstreams
101 changes: 97 additions & 4 deletions src/components/mobile/MobileFormInput.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
import React, { useState } from 'react';
import { View, TextInput, TextInputProps, TouchableOpacity, StyleSheet } from 'react-native';
import React, { useEffect, useState } from 'react';
import { Eye, EyeOff, AlertCircle } from 'lucide-react-native';
import { AppText as Text } from '../common/AppText';
import {
View,
TextInput,
TextInputProps,
TouchableOpacity,
StyleSheet,
} from 'react-native';
import { useDynamicFontSize } from '../../hooks';
import {
formCacheService,
setCachedFieldValue,
type FormCacheFieldKey,
} from '../../services/formCache';
import { AppText as Text } from '../common/AppText';

/**
* Props for the MobileFormInput component
Expand All @@ -24,6 +35,10 @@ interface MobileFormInputProps extends TextInputProps {
required?: boolean;
/** Whether to use dark mode styling */
isDark?: boolean;
/** Shared cache key for autofill and suggestions */
cacheKey?: FormCacheFieldKey;
/** Persist value to cache on blur (default: true when cacheKey is set) */
cacheOnBlur?: boolean;
}

export const MobileFormInput: React.FC<MobileFormInputProps> = ({
Expand All @@ -36,16 +51,53 @@ export const MobileFormInput: React.FC<MobileFormInputProps> = ({
leftIcon,
required = false,
isDark = false,
cacheKey,
cacheOnBlur = true,
secureTextEntry,
multiline = false,
keyboardType = 'default',
onBlur,
...rest
}) => {
const [isFocused, setIsFocused] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [suggestion, setSuggestion] = useState<string | null>(null);
const { scale } = useDynamicFontSize();
const isPassword = secureTextEntry === true;

useEffect(() => {
if (!cacheKey || !isFocused) {
setSuggestion(null);
return;
}

let cancelled = false;
void (async () => {
const store = await formCacheService.loadFormCache();
if (cancelled) return;
setSuggestion(formCacheService.getSuggestionForField(store, cacheKey, value));
})();

return () => {
cancelled = true;
};
}, [cacheKey, isFocused, value]);

const handleBlur = (e: Parameters<NonNullable<TextInputProps['onBlur']>>[0]) => {
setIsFocused(false);
if (cacheKey && cacheOnBlur && value.trim()) {
void setCachedFieldValue(cacheKey, value);
}
onBlur?.(e);
};

const handleApplySuggestion = () => {
if (suggestion) {
onChangeText(suggestion);
setSuggestion(null);
}
};

const borderColor = error ? '#ef4444' : isFocused ? '#19c3e6' : isDark ? '#334155' : '#e2e8f0';

const labelColor = error ? '#ef4444' : isDark ? '#94a3b8' : '#64748b';
Expand Down Expand Up @@ -94,7 +146,7 @@ export const MobileFormInput: React.FC<MobileFormInputProps> = ({
value={value}
onChangeText={onChangeText}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onBlur={handleBlur}
secureTextEntry={isPassword && !showPassword}
multiline={multiline}
keyboardType={keyboardType}
Expand All @@ -112,6 +164,28 @@ export const MobileFormInput: React.FC<MobileFormInputProps> = ({
)}
</View>

{suggestion && !error && (
<TouchableOpacity
style={[
styles.suggestionRow,
{ backgroundColor: isDark ? '#0f172a' : '#f0f9ff', borderColor: isDark ? '#334155' : '#bae6fd' },
]}
onPress={handleApplySuggestion}
accessibilityRole="button"
accessibilityLabel={`Use cached value ${suggestion}`}
>
<Text style={[styles.suggestionLabel, { color: isDark ? '#94a3b8' : '#64748b', fontSize: scale(12) }]}>
Use saved:
</Text>
<Text
style={[styles.suggestionValue, { color: isDark ? '#38bdf8' : '#0284c7', fontSize: scale(12) }]}
numberOfLines={1}
>
{suggestion}
</Text>
</TouchableOpacity>
)}

{error && (
<View style={styles.errorRow}>
<AlertCircle size={scale(14)} color="#ef4444" />
Expand Down Expand Up @@ -176,4 +250,23 @@ const styles = StyleSheet.create({
color: '#ef4444',
flex: 1,
},
suggestionRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
marginTop: 6,
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 10,
borderWidth: 1,
},
suggestionLabel: {
fontSize: 12,
fontWeight: '500',
},
suggestionValue: {
flex: 1,
fontSize: 12,
fontWeight: '600',
},
});
Loading
Loading