From 9ff06f8e3b7b64d9c9f828f9f62c99e108ec18f9 Mon Sep 17 00:00:00 2001 From: Pablo Giraud-Carrier Date: Sat, 6 Jun 2026 13:49:08 +0200 Subject: [PATCH 1/7] feat: add useDetectedEntities reactive hook (as-you-type) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a reactive, debounced detection hook for a changing string — ideal for as-you-type input. Pass text, get { entities, isDetecting, status, error }; cancellation-safe (latest text wins), skips empty input, manages model readiness internally. Factors the model-readiness/auto-prepare logic out of useDataDetector into a shared internal useModelLifecycle hook, used by both public hooks. useDataDetector's public API is unchanged. Version bump and changelog cut left for release time. --- CHANGELOG.md | 3 + README.md | 55 +++++++++++++++-- src/index.ts | 2 + src/useDataDetector.ts | 73 ++++------------------- src/useDetectedEntities.ts | 119 +++++++++++++++++++++++++++++++++++++ src/useModelLifecycle.ts | 85 ++++++++++++++++++++++++++ 6 files changed, 271 insertions(+), 66 deletions(-) create mode 100644 src/useDetectedEntities.ts create mode 100644 src/useModelLifecycle.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ab61388..ef2cca5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `useDetectedEntities(text, options?)` hook — reactive, debounced detection of a + changing string (as-you-type). Returns `{ entities, isDetecting, status, error }`, + is cancellation-safe (latest text wins), and manages model readiness internally. - `useDataDetector` hook — tracks model readiness (`status`/`isReady`/`error`), exposes `detect`, and auto-downloads the model on Android (opt out with `autoPrepare: false`). diff --git a/README.md b/README.md index 89609fa..c8904c5 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Cross-platform text data detection for React Native. Uses **NSDataDetector** on - **Addresses** — Detect street addresses with parsed components (iOS) - **Dates** — Detect dates and times with ISO 8601 output - **Native accuracy** — Uses battle-tested platform APIs instead of regex -- **React hook** — [`useDataDetector`](#usedatadetectoroptions) tracks model readiness and auto-downloads on Android +- **React hooks** — `useDataDetector` (imperative) and `useDetectedEntities` (reactive, as-you-type); both track model readiness and auto-download on Android - **Multiple languages** — Choose from 15 ML Kit language models on Android - **Expo Modules API** — Built with the modern Expo native module system @@ -65,10 +65,17 @@ const phones = await detect('Call 555-1234 or visit https://example.com', { const fr = await detect('Appelez-moi au 01 23 45 67 89', { language: 'fr' }); ``` -### Hook +### Hooks -The `useDataDetector` hook tracks model readiness and, on Android, downloads the -model automatically on mount. On iOS the model is always ready. +There are two hooks for two situations: + +- **`useDataDetector`** — *imperative*. Tracks model readiness and hands you a + `detect` function you call yourself (e.g. once per chat message). +- **`useDetectedEntities`** — *reactive*. Pass it a (changing) string and it returns + the detected entities, debounced and recomputed as the text changes — ideal for + as-you-type input. + +Both download the model automatically on Android and are no-ops on iOS (always ready). ```tsx import { useDataDetector } from 'react-native-data-detector'; @@ -87,6 +94,22 @@ function MyComponent() { } ``` +```tsx +import { useDetectedEntities } from 'react-native-data-detector'; + +function LiveInput() { + const [text, setText] = useState(''); + const { entities, isDetecting } = useDetectedEntities(text, { debounceMs: 250 }); + + return ( + <> + + {entities.length} detected{isDetecting ? '…' : ''} + + ); +} +``` + ## API ### `prepareModel(options?)` @@ -145,6 +168,30 @@ React hook that tracks model availability and, on Android, downloads the languag | `isReady` | `boolean` | `true` when `status === 'ready'`. | | `error` | `Error \| null` | The last preparation error, or `null`. | +### `useDetectedEntities(text, options?)` + +Reactive hook: pass a (changing) string and get back the detected entities, recomputed as the text changes. Debounced and cancellation-safe (the latest text wins), so it suits as-you-type input. Manages model readiness internally. + +**Parameters:** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `text` | `string` | — | The text to detect entities in. | +| `options.debounceMs` | `number` | `300` | Debounce applied to `text` before detecting. | +| `options.types` | `DetectionType[]` | All types | Which entity types to detect. | +| `options.language` | `ModelLanguage` | `'en'` | Which language model to use (Android only). | +| `options.enabled` | `boolean` | `true` | When `false`, detection pauses and the last result is kept. | +| `options.autoPrepare` | `boolean` | `true` | Download the model on mount if needed (Android). | + +**Returns:** `UseDetectedEntitiesResult` + +| Property | Type | Description | +|----------|------|-------------| +| `entities` | `DetectedEntity[]` | Entities detected in the debounced `text`. | +| `isDetecting` | `boolean` | `true` while a detection for the latest text is in flight. | +| `status` | `ModelStatus` | Current model download state. | +| `error` | `Error \| null` | The last detection or model error, or `null`. | + ### `downloadModel()` > **Deprecated since 0.3.0** — use [`prepareModel()`](#preparemodeloptions) instead. Kept as an alias (always targets the default `'en'` model) and will be removed in a future major version. diff --git a/src/index.ts b/src/index.ts index afe8ba1..4d84863 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,8 @@ export { } from './ReactNativeDataDetector'; export { useDataDetector } from './useDataDetector'; export type { UseDataDetectorOptions, UseDataDetectorResult } from './useDataDetector'; +export { useDetectedEntities } from './useDetectedEntities'; +export type { UseDetectedEntitiesOptions, UseDetectedEntitiesResult } from './useDetectedEntities'; export type { DetectedEntity, DetectionType, diff --git a/src/useDataDetector.ts b/src/useDataDetector.ts index dc73bba..306ab04 100644 --- a/src/useDataDetector.ts +++ b/src/useDataDetector.ts @@ -1,14 +1,13 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback } from 'react'; -import { detect as detectFn, getModelStatus, prepareModel } from './ReactNativeDataDetector'; +import { detect as detectFn } from './ReactNativeDataDetector'; import type { DetectedEntity, DetectionType, ModelLanguage, ModelStatus, } from './ReactNativeDataDetector.types'; - -const DEFAULT_LANGUAGE: ModelLanguage = 'en'; +import { DEFAULT_LANGUAGE, useModelLifecycle } from './useModelLifecycle'; export interface UseDataDetectorOptions { /** @@ -42,8 +41,12 @@ export interface UseDataDetectorResult { } /** - * React hook for data detection that tracks model availability and, on Android, - * downloads the language model automatically. + * React hook for **imperative** data detection: it tracks model availability and, + * on Android, downloads the language model automatically, then hands you a `detect` + * function to call when you want (e.g. once per chat message). + * + * For **reactive** detection of a changing string (as-you-type), use + * {@link useDetectedEntities} instead. * * On iOS the model is always available, so `status` settles on `'ready'` and * `autoPrepare` has no effect. @@ -56,61 +59,7 @@ export function useDataDetector(options?: UseDataDetectorOptions): UseDataDetect const language = options?.language ?? DEFAULT_LANGUAGE; const autoPrepare = options?.autoPrepare ?? true; - const [status, setStatus] = useState('notDownloaded'); - const [error, setError] = useState(null); - - // The language the hook currently cares about. Lets late-resolving promises - // from a previous language (after a switch or unmount) be ignored. - const activeLanguage = useRef(language); - - const prepare = useCallback(async () => { - setError(null); - setStatus('downloading'); - try { - await prepareModel({ language }); - if (activeLanguage.current === language) setStatus('ready'); - } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); - if (activeLanguage.current === language) { - setError(err); - setStatus('error'); - } - throw err; - } - }, [language]); - - useEffect(() => { - activeLanguage.current = language; - let active = true; - - (async () => { - setError(null); - try { - const current = await getModelStatus({ language }); - if (!active) return; - if (current === 'ready') { - setStatus('ready'); - return; - } - if (!autoPrepare) { - setStatus(current); - return; - } - setStatus('downloading'); - await prepareModel({ language }); - if (active) setStatus('ready'); - } catch (e) { - if (active) { - setError(e instanceof Error ? e : new Error(String(e))); - setStatus('error'); - } - } - })(); - - return () => { - active = false; - }; - }, [language, autoPrepare]); + const { status, isReady, prepare, error } = useModelLifecycle(language, autoPrepare); const detect = useCallback( (text: string, opts?: { types?: DetectionType[] }): Promise => @@ -118,5 +67,5 @@ export function useDataDetector(options?: UseDataDetectorOptions): UseDataDetect [language], ); - return { detect, prepare, status, isReady: status === 'ready', error }; + return { detect, prepare, status, isReady, error }; } diff --git a/src/useDetectedEntities.ts b/src/useDetectedEntities.ts new file mode 100644 index 0000000..85228b2 --- /dev/null +++ b/src/useDetectedEntities.ts @@ -0,0 +1,119 @@ +import { useEffect, useRef, useState } from 'react'; + +import { detect as detectFn } from './ReactNativeDataDetector'; +import type { + DetectedEntity, + DetectionType, + ModelLanguage, + ModelStatus, +} from './ReactNativeDataDetector.types'; +import { DEFAULT_LANGUAGE, useModelLifecycle } from './useModelLifecycle'; + +export interface UseDetectedEntitiesOptions { + /** Debounce applied to `text` before detecting, in ms. Defaults to `300`. */ + debounceMs?: number; + /** Which entity types to detect. Defaults to all types. */ + types?: DetectionType[]; + /** + * Which language model to use (Android only, defaults to `'en'`). Ignored on iOS. + */ + language?: ModelLanguage; + /** When `false`, detection is paused and the last result is kept. Defaults to `true`. */ + enabled?: boolean; + /** + * When `true` (default), download the model on mount if needed (Android). + * No effect on iOS. + */ + autoPrepare?: boolean; +} + +export interface UseDetectedEntitiesResult { + /** Entities detected in the (debounced) `text`. Empty until the first result. */ + entities: DetectedEntity[]; + /** `true` while a detection for the latest text is in flight. */ + isDetecting: boolean; + /** Current model download state. */ + status: ModelStatus; + /** The last detection or model error, or `null`. */ + error: Error | null; +} + +/** + * React hook for **reactive** data detection: pass a (possibly changing) string and + * get back the detected entities, recomputed as the text changes. Debounced and + * cancellation-safe (last write wins), so it is suited to as-you-type input. + * + * Manages model readiness internally (auto-downloads on Android). For **imperative** + * detection where you call `detect` yourself, use {@link useDataDetector} instead. + * + * @example + * const { entities, isDetecting } = useDetectedEntities(text, { debounceMs: 250 }); + */ +export function useDetectedEntities( + text: string, + options?: UseDetectedEntitiesOptions, +): UseDetectedEntitiesResult { + const debounceMs = options?.debounceMs ?? 300; + const language = options?.language ?? DEFAULT_LANGUAGE; + const enabled = options?.enabled ?? true; + const autoPrepare = options?.autoPrepare ?? true; + const types = options?.types; + + const { status, isReady, error: modelError } = useModelLifecycle(language, autoPrepare); + + const [entities, setEntities] = useState([]); + const [isDetecting, setIsDetecting] = useState(false); + const [detectError, setDetectError] = useState(null); + + // Debounce the incoming text. + const [debouncedText, setDebouncedText] = useState(text); + useEffect(() => { + if (!enabled) return; + const id = setTimeout(() => setDebouncedText(text), debounceMs); + return () => clearTimeout(id); + }, [text, debounceMs, enabled]); + + // `types` is an array; depend on a stable string key and read the latest value + // from a ref so a new array identity each render doesn't re-trigger detection. + const typesKey = types ? types.join(',') : ''; + const typesRef = useRef(types); + typesRef.current = types; + + // Monotonic id so only the most recent detection can commit its result. + const runId = useRef(0); + + useEffect(() => { + if (!enabled || !isReady) return; + + if (!debouncedText) { + runId.current += 1; // invalidate any in-flight detection + setEntities([]); + setIsDetecting(false); + setDetectError(null); + return; + } + + const myRun = ++runId.current; + setIsDetecting(true); + setDetectError(null); + + detectFn(debouncedText, { types: typesRef.current, language }) + .then((res) => { + if (myRun !== runId.current) return; // a newer run superseded this one + setEntities(res); + setIsDetecting(false); + }) + .catch((e) => { + if (myRun !== runId.current) return; + setDetectError(e instanceof Error ? e : new Error(String(e))); + setIsDetecting(false); + }); + }, [debouncedText, isReady, enabled, language, typesKey]); + + return { + entities, + isDetecting, + status, + error: detectError ?? modelError, + }; +} diff --git a/src/useModelLifecycle.ts b/src/useModelLifecycle.ts new file mode 100644 index 0000000..cdecb21 --- /dev/null +++ b/src/useModelLifecycle.ts @@ -0,0 +1,85 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { getModelStatus, prepareModel } from './ReactNativeDataDetector'; +import type { ModelLanguage, ModelStatus } from './ReactNativeDataDetector.types'; + +export const DEFAULT_LANGUAGE: ModelLanguage = 'en'; + +export interface ModelLifecycle { + /** Current model download state. */ + status: ModelStatus; + /** `true` when the model is available and `detect()` can run offline. */ + isReady: boolean; + /** Manually (re)download the model for the configured language. */ + prepare: () => Promise; + /** The last preparation error, or `null`. */ + error: Error | null; +} + +/** + * Internal hook shared by {@link useDataDetector} and {@link useDetectedEntities}. + * Tracks model availability for a language and, on Android, downloads it + * automatically on mount when `autoPrepare` is set. On iOS the model is always + * available, so `status` settles on `'ready'`. + * + * Not part of the public API. + */ +export function useModelLifecycle(language: ModelLanguage, autoPrepare: boolean): ModelLifecycle { + const [status, setStatus] = useState('notDownloaded'); + const [error, setError] = useState(null); + + // The language the hook currently cares about. Lets late-resolving promises + // from a previous language (after a switch or unmount) be ignored. + const activeLanguage = useRef(language); + + const prepare = useCallback(async () => { + setError(null); + setStatus('downloading'); + try { + await prepareModel({ language }); + if (activeLanguage.current === language) setStatus('ready'); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + if (activeLanguage.current === language) { + setError(err); + setStatus('error'); + } + throw err; + } + }, [language]); + + useEffect(() => { + activeLanguage.current = language; + let active = true; + + (async () => { + setError(null); + try { + const current = await getModelStatus({ language }); + if (!active) return; + if (current === 'ready') { + setStatus('ready'); + return; + } + if (!autoPrepare) { + setStatus(current); + return; + } + setStatus('downloading'); + await prepareModel({ language }); + if (active) setStatus('ready'); + } catch (e) { + if (active) { + setError(e instanceof Error ? e : new Error(String(e))); + setStatus('error'); + } + } + })(); + + return () => { + active = false; + }; + }, [language, autoPrepare]); + + return { status, isReady: status === 'ready', prepare, error }; +} From 103beabbb047971422aa43420d8927030bd871aa Mon Sep 17 00:00:00 2001 From: Pablo Giraud-Carrier Date: Sat, 6 Jun 2026 13:59:44 +0200 Subject: [PATCH 2/7] example: showcase both hooks (reactive useDetectedEntities + imperative useDataDetector) Adds a Detection Mode toggle so the example demonstrates the full API: as-you-type detection via useDetectedEntities, on-tap detection via useDataDetector, plus model status and language selection. Validated on Android: both modes detect correctly. --- example/App.tsx | 198 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 150 insertions(+), 48 deletions(-) diff --git a/example/App.tsx b/example/App.tsx index d53c981..e56577f 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -13,6 +13,7 @@ import { } from 'react-native'; import { useDataDetector, + useDetectedEntities, type DetectedEntity, type DetectionType, type ModelLanguage, @@ -50,16 +51,39 @@ const STATUS_LABELS: Record = { error: 'Model error', }; +type Mode = 'reactive' | 'imperative'; + export default function App() { const [text, setText] = useState(SAMPLE_TEXT); const [selectedTypes, setSelectedTypes] = useState>(new Set(ALL_TYPES)); - const [results, setResults] = useState([]); - const [loading, setLoading] = useState(false); - const [detectError, setDetectError] = useState(null); const [language, setLanguage] = useState('en'); + const [mode, setMode] = useState('reactive'); + + const types = Array.from(selectedTypes); + + // Imperative hook: model lifecycle (status/prepare) + a detect() you call yourself. + const { detect, status, isReady, prepare, error: modelError } = useDataDetector({ language }); + + // Reactive hook: debounced detection as `text` changes. Paused in imperative mode. + const { + entities: liveEntities, + isDetecting, + error: liveError, + } = useDetectedEntities(text, { + types, + language, + debounceMs: 250, + enabled: mode === 'reactive', + }); - // The hook tracks model readiness and auto-downloads on Android. - const { detect, status, isReady, prepare, error } = useDataDetector({ language }); + // Imperative-mode result state, populated by the Detect button. + const [tappedEntities, setTappedEntities] = useState([]); + const [detecting, setDetecting] = useState(false); + const [tapError, setTapError] = useState(null); + + const entities = mode === 'reactive' ? liveEntities : tappedEntities; + const busy = mode === 'reactive' ? isDetecting : detecting; + const detectionError = mode === 'reactive' ? liveError : tapError; const toggleType = (type: DetectionType) => { setSelectedTypes((prev) => { @@ -74,18 +98,16 @@ export default function App() { }; const handleDetect = async () => { - if (!text.trim()) return; - setLoading(true); - setDetectError(null); + setDetecting(true); + setTapError(null); try { - const types = Array.from(selectedTypes); - const entities = await detect(text, types.length < ALL_TYPES.length ? { types } : undefined); - setResults(entities); + const res = await detect(text, { types }); + setTappedEntities(res); } catch (e: any) { - setResults([]); - setDetectError(`Detection error: ${e.message}`); + setTappedEntities([]); + setTapError(e instanceof Error ? e : new Error(String(e))); } finally { - setLoading(false); + setDetecting(false); } }; @@ -98,6 +120,7 @@ export default function App() { {Platform.OS === 'ios' ? 'NSDataDetector' : 'ML Kit Entity Extraction'} + {/* Model lifecycle: getModelStatus / isModelReady / prepareModel (Android). */} {Platform.OS === 'android' && ( Language Model @@ -120,17 +143,44 @@ export default function App() { {status === 'downloading' && } - {error ? `Error: ${error.message}` : (STATUS_LABELS[status] ?? status)} + {modelError ? `Error: ${modelError.message}` : (STATUS_LABELS[status] ?? status)} {status === 'error' && ( - prepare().catch(() => {})}> - Retry Download + prepare().catch(() => {})}> + Retry Download )} )} + {/* Two hooks, two modes. */} + + Detection Mode + + {( + [ + ['reactive', 'As you type', 'useDetectedEntities'], + ['imperative', 'On tap', 'useDataDetector'], + ] as const + ).map(([value, title, sub]) => { + const active = mode === value; + return ( + setMode(value)} + > + + {title} + + {sub} + + ); + })} + + + Input Text @@ -166,36 +216,47 @@ export default function App() { - - {loading ? ( - - ) : ( - - {isReady ? 'Detect Entities' : 'Preparing model…'} - - )} - + {mode === 'imperative' && ( + + {detecting ? ( + + ) : ( + + {isReady ? 'Detect Entities' : 'Preparing model…'} + + )} + + )} - {detectError && {detectError}} + {detectionError && ( + Error: {detectionError.message} + )} - {results.length > 0 && ( - + + - Results ({results.length} {results.length === 1 ? 'entity' : 'entities'}) + Results ({entities.length} {entities.length === 1 ? 'entity' : 'entities'}) + + {busy && } + + + {entities.length === 0 ? ( + + {status !== 'ready' + ? 'Preparing model…' + : mode === 'reactive' + ? 'No entities — keep typing…' + : 'Tap “Detect Entities” to analyze.'} - {results.map((entity, index) => ( - + ) : ( + entities.map((entity, index) => ( + - + {TYPE_LABELS[entity.type]} @@ -213,9 +274,9 @@ export default function App() { )} - ))} - - )} + )) + )} + ); @@ -279,6 +340,40 @@ const styles = StyleSheet.create({ chipTextActive: { color: '#fff', }, + segment: { + flexDirection: 'row', + gap: 8, + }, + segmentItem: { + flex: 1, + backgroundColor: '#fff', + borderRadius: 12, + borderWidth: 1.5, + borderColor: '#E5E5EA', + paddingVertical: 10, + alignItems: 'center', + }, + segmentItemActive: { + borderColor: '#007AFF', + backgroundColor: '#007AFF', + }, + segmentTitle: { + fontSize: 15, + fontWeight: '600', + color: '#333', + }, + segmentTitleActive: { + color: '#fff', + }, + segmentSub: { + fontSize: 11, + color: '#8E8E93', + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + marginTop: 2, + }, + segmentSubActive: { + color: '#D6E6FF', + }, detectButton: { backgroundColor: '#007AFF', borderRadius: 12, @@ -294,13 +389,14 @@ const styles = StyleSheet.create({ fontSize: 17, fontWeight: '600', }, - downloadButton: { + retryButton: { backgroundColor: '#34C759', borderRadius: 12, paddingVertical: 12, alignItems: 'center', + marginTop: 10, }, - downloadButtonText: { + retryButtonText: { color: '#fff', fontSize: 15, fontWeight: '600', @@ -330,6 +426,12 @@ const styles = StyleSheet.create({ backgroundColor: '#007AFF', borderColor: '#007AFF', }, + resultsHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 8, + }, card: { backgroundColor: '#fff', borderRadius: 12, From f0ed544bed2303c5d765351e0ebf1dca6e81c49c Mon Sep 17 00:00:00 2001 From: Pablo Giraud-Carrier Date: Sat, 6 Jun 2026 14:08:58 +0200 Subject: [PATCH 3/7] =?UTF-8?q?example:=20premium=20redesign=20=E2=80=94?= =?UTF-8?q?=20keyboard-safe,=20compact=20controls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Detected entities now render in a horizontal strip ABOVE the input, so they stay visible while typing (KeyboardAvoidingView keeps input + results above the keyboard). - Mode (Live/On tap) is a compact pill toggle; language is a bottom-sheet dropdown (all 15 models). Dropped the entity-type filter. - Refined dark theme. Validated on Android: live detection updates above the keyboard while typing. --- example/App.tsx | 682 ++++++++++++++++++++++-------------------------- 1 file changed, 316 insertions(+), 366 deletions(-) diff --git a/example/App.tsx b/example/App.tsx index e56577f..26275ea 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -2,6 +2,8 @@ import { StatusBar } from 'expo-status-bar'; import { useState } from 'react'; import { ActivityIndicator, + KeyboardAvoidingView, + Modal, Platform, Pressable, SafeAreaView, @@ -19,19 +21,25 @@ import { type ModelLanguage, } from 'react-native-data-detector'; -const SAMPLE_TEXT = - 'Call me at (555) 123-4567 or email john@example.com.\n' + - 'Visit https://reactnative.dev for docs.\n' + - 'Meet me at 1 Infinite Loop, Cupertino, CA 95014 on March 15, 2025.'; +const SAMPLE_TEXT = 'Call me at (555) 123-4567 or email john@example.com tomorrow at 9:30pm.'; -const ALL_TYPES: DetectionType[] = ['phoneNumber', 'link', 'email', 'address', 'date']; +// — Theme ————————————————————————————————————————————————————————————— +const C = { + bg: '#0B0C10', + surface: '#16181D', + surfaceHi: '#1E2128', + border: 'rgba(255,255,255,0.08)', + text: '#F4F4F6', + muted: '#8B8D98', + accent: '#6366F1', +}; const TYPE_COLORS: Record = { - phoneNumber: '#5856D6', - link: '#007AFF', - email: '#34C759', - address: '#FF9500', - date: '#FF2D55', + phoneNumber: '#A78BFA', + link: '#60A5FA', + email: '#34D399', + address: '#FBBF24', + date: '#F472B6', }; const TYPE_LABELS: Record = { @@ -42,438 +50,380 @@ const TYPE_LABELS: Record = { date: 'Date', }; -const LANGUAGES: ModelLanguage[] = ['en', 'fr', 'es', 'de', 'ja', 'zh']; - -const STATUS_LABELS: Record = { - notDownloaded: 'Model not downloaded', - downloading: 'Downloading model…', - ready: 'Model ready', - error: 'Model error', -}; +const LANGUAGES: { code: ModelLanguage; name: string }[] = [ + { code: 'ar', name: 'Arabic' }, + { code: 'nl', name: 'Dutch' }, + { code: 'en', name: 'English' }, + { code: 'fr', name: 'French' }, + { code: 'de', name: 'German' }, + { code: 'it', name: 'Italian' }, + { code: 'ja', name: 'Japanese' }, + { code: 'ko', name: 'Korean' }, + { code: 'pl', name: 'Polish' }, + { code: 'pt', name: 'Portuguese' }, + { code: 'ru', name: 'Russian' }, + { code: 'es', name: 'Spanish' }, + { code: 'th', name: 'Thai' }, + { code: 'tr', name: 'Turkish' }, + { code: 'zh', name: 'Chinese' }, +]; type Mode = 'reactive' | 'imperative'; export default function App() { const [text, setText] = useState(SAMPLE_TEXT); - const [selectedTypes, setSelectedTypes] = useState>(new Set(ALL_TYPES)); const [language, setLanguage] = useState('en'); const [mode, setMode] = useState('reactive'); + const [langOpen, setLangOpen] = useState(false); - const types = Array.from(selectedTypes); + // Imperative hook: model lifecycle + a detect() you call yourself. + const { detect, status, isReady } = useDataDetector({ language }); - // Imperative hook: model lifecycle (status/prepare) + a detect() you call yourself. - const { detect, status, isReady, prepare, error: modelError } = useDataDetector({ language }); - - // Reactive hook: debounced detection as `text` changes. Paused in imperative mode. - const { - entities: liveEntities, - isDetecting, - error: liveError, - } = useDetectedEntities(text, { - types, + // Reactive hook: debounced detection as `text` changes (paused in "On tap" mode). + const { entities: liveEntities, isDetecting } = useDetectedEntities(text, { language, - debounceMs: 250, + debounceMs: 200, enabled: mode === 'reactive', }); - // Imperative-mode result state, populated by the Detect button. const [tappedEntities, setTappedEntities] = useState([]); const [detecting, setDetecting] = useState(false); - const [tapError, setTapError] = useState(null); const entities = mode === 'reactive' ? liveEntities : tappedEntities; const busy = mode === 'reactive' ? isDetecting : detecting; - const detectionError = mode === 'reactive' ? liveError : tapError; - - const toggleType = (type: DetectionType) => { - setSelectedTypes((prev) => { - const next = new Set(prev); - if (next.has(type)) { - next.delete(type); - } else { - next.add(type); - } - return next; - }); - }; const handleDetect = async () => { setDetecting(true); - setTapError(null); try { - const res = await detect(text, { types }); - setTappedEntities(res); - } catch (e: any) { + setTappedEntities(await detect(text)); + } catch { setTappedEntities([]); - setTapError(e instanceof Error ? e : new Error(String(e))); } finally { setDetecting(false); } }; + const languageName = LANGUAGES.find((l) => l.code === language)?.name ?? language; + return ( - - - Data Detector - - {Platform.OS === 'ios' ? 'NSDataDetector' : 'ML Kit Entity Extraction'} - - - {/* Model lifecycle: getModelStatus / isModelReady / prepareModel (Android). */} - {Platform.OS === 'android' && ( - - Language Model - - {LANGUAGES.map((lang) => { - const active = language === lang; + + + + + Data Detector + + {Platform.OS === 'ios' ? 'NSDataDetector' : 'ML Kit Entity Extraction'} + + + + {/* Compact controls: language dropdown + mode toggle */} + + {Platform.OS === 'android' ? ( + setLangOpen(true)}> + {languageName} + + + ) : ( + + )} + + + {( + [ + ['reactive', 'Live'], + ['imperative', 'On tap'], + ] as const + ).map(([value, label]) => { + const active = mode === value; return ( setLanguage(lang)} + key={value} + style={[styles.toggleItem, active && styles.toggleItemActive]} + onPress={() => setMode(value)} > - - {lang.toUpperCase()} + + {label} ); })} + + + {/* Subtle model status — only when not ready */} + {Platform.OS === 'android' && status !== 'ready' && ( - {status === 'downloading' && } + {status === 'downloading' && } - {modelError ? `Error: ${modelError.message}` : (STATUS_LABELS[status] ?? status)} + {status === 'downloading' + ? 'Downloading model…' + : status === 'error' + ? 'Model error' + : 'Model not downloaded'} - {status === 'error' && ( - prepare().catch(() => {})}> - Retry Download - - )} - - )} - - {/* Two hooks, two modes. */} - - Detection Mode - - {( - [ - ['reactive', 'As you type', 'useDetectedEntities'], - ['imperative', 'On tap', 'useDataDetector'], - ] as const - ).map(([value, title, sub]) => { - const active = mode === value; - return ( - setMode(value)} - > - - {title} - - {sub} - - ); - })} - - + )} - - Input Text - - + - - Entity Types - - {ALL_TYPES.map((type) => { - const active = selectedTypes.has(type); - return ( - toggleType(type)} - > - - {TYPE_LABELS[type]} - - - ); - })} + {/* Detected — horizontal, above the input so it stays clear of the keyboard */} + + Detected · {entities.length} + {busy && } - - {mode === 'imperative' && ( - - {detecting ? ( - - ) : ( - - {isReady ? 'Detect Entities' : 'Preparing model…'} + {entities.length === 0 ? ( + + + {status !== 'ready' && Platform.OS === 'android' + ? 'Preparing model…' + : mode === 'reactive' + ? 'Start typing to detect…' + : 'Tap Detect to analyze.'} - )} - - )} - - {detectionError && ( - Error: {detectionError.message} - )} + + ) : ( + + {entities.map((entity, index) => { + const color = TYPE_COLORS[entity.type]; + return ( + + + + {TYPE_LABELS[entity.type]} + + + {entity.text} + + + ); + })} + + )} - - - - Results ({entities.length} {entities.length === 1 ? 'entity' : 'entities'}) - - {busy && } + {/* Input */} + + - {entities.length === 0 ? ( - - {status !== 'ready' - ? 'Preparing model…' - : mode === 'reactive' - ? 'No entities — keep typing…' - : 'Tap “Detect Entities” to analyze.'} - - ) : ( - entities.map((entity, index) => ( - - - - {TYPE_LABELS[entity.type]} - - - [{entity.start}–{entity.end}] - - - "{entity.text}" - {entity.data && Object.keys(entity.data).length > 0 && ( - - {Object.entries(entity.data).map(([key, value]) => ( - - {key}: {value} - - ))} - - )} - - )) + {mode === 'imperative' && ( + + {detecting ? ( + + ) : ( + {isReady ? 'Detect' : 'Preparing model…'} + )} + )} - + + + {/* Language picker sheet */} + setLangOpen(false)} + > + setLangOpen(false)}> + {}}> + Language model + + {LANGUAGES.map((l) => { + const active = l.code === language; + return ( + { + setLanguage(l.code); + setLangOpen(false); + }} + > + + {l.name} + + {active && } + + ); + })} + + + + ); } const styles = StyleSheet.create({ - container: { + container: { flex: 1, backgroundColor: C.bg }, + flex: { flex: 1 }, + body: { flex: 1, - backgroundColor: '#F2F2F7', - }, - content: { - padding: 20, - paddingTop: Platform.OS === 'android' ? 48 : 20, - }, - title: { - fontSize: 28, - fontWeight: '700', - color: '#000', + paddingHorizontal: 20, + paddingTop: Platform.OS === 'android' ? 44 : 12, + paddingBottom: 16, }, + header: { marginBottom: 20 }, + title: { fontSize: 30, fontWeight: '800', color: C.text, letterSpacing: -0.5 }, subtitle: { - fontSize: 14, - color: '#8E8E93', - marginBottom: 20, - }, - section: { - marginBottom: 20, - }, - label: { fontSize: 13, - fontWeight: '600', - color: '#8E8E93', - textTransform: 'uppercase', - marginBottom: 8, - }, - textInput: { - backgroundColor: '#fff', - borderRadius: 12, - padding: 14, - fontSize: 15, - minHeight: 100, - textAlignVertical: 'top', - color: '#000', - }, - chips: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 8, - }, - chip: { - paddingHorizontal: 14, - paddingVertical: 7, - borderRadius: 20, - borderWidth: 1.5, - backgroundColor: '#fff', - }, - chipText: { - fontSize: 14, - fontWeight: '500', - color: '#333', - }, - chipTextActive: { - color: '#fff', - }, - segment: { - flexDirection: 'row', - gap: 8, - }, - segmentItem: { - flex: 1, - backgroundColor: '#fff', - borderRadius: 12, - borderWidth: 1.5, - borderColor: '#E5E5EA', - paddingVertical: 10, - alignItems: 'center', - }, - segmentItemActive: { - borderColor: '#007AFF', - backgroundColor: '#007AFF', - }, - segmentTitle: { - fontSize: 15, - fontWeight: '600', - color: '#333', - }, - segmentTitleActive: { - color: '#fff', - }, - segmentSub: { - fontSize: 11, - color: '#8E8E93', - fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + color: C.muted, marginTop: 2, + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', }, - segmentSubActive: { - color: '#D6E6FF', - }, - detectButton: { - backgroundColor: '#007AFF', - borderRadius: 12, - paddingVertical: 14, - alignItems: 'center', - marginBottom: 20, - }, - detectButtonDisabled: { - opacity: 0.6, - }, - detectButtonText: { - color: '#fff', - fontSize: 17, - fontWeight: '600', - }, - retryButton: { - backgroundColor: '#34C759', - borderRadius: 12, - paddingVertical: 12, + + controls: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, + dropdown: { + flexDirection: 'row', alignItems: 'center', - marginTop: 10, - }, - retryButtonText: { - color: '#fff', - fontSize: 15, - fontWeight: '600', + gap: 6, + backgroundColor: C.surface, + borderWidth: 1, + borderColor: C.border, + borderRadius: 999, + paddingVertical: 8, + paddingHorizontal: 14, }, - statusRow: { + dropdownText: { color: C.text, fontSize: 14, fontWeight: '600' }, + caret: { color: C.muted, fontSize: 11, marginTop: 1 }, + + toggle: { flexDirection: 'row', + backgroundColor: C.surface, + borderRadius: 999, + borderWidth: 1, + borderColor: C.border, + padding: 3, + }, + toggleItem: { paddingVertical: 7, paddingHorizontal: 16, borderRadius: 999 }, + toggleItemActive: { backgroundColor: C.accent }, + toggleText: { color: C.muted, fontSize: 13, fontWeight: '600' }, + toggleTextActive: { color: '#fff' }, + + statusRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: 12 }, + statusText: { color: C.muted, fontSize: 13 }, + + spacer: { flex: 1, minHeight: 16 }, + + detectedHeader: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 10 }, + label: { + fontSize: 12, + fontWeight: '700', + color: C.muted, + textTransform: 'uppercase', + letterSpacing: 1, + }, + emptyStrip: { + height: 76, + borderRadius: 14, + borderWidth: 1, + borderColor: C.border, + borderStyle: 'dashed', alignItems: 'center', justifyContent: 'center', - gap: 8, - marginTop: 10, - }, - statusText: { - fontSize: 13, - color: '#8E8E93', - textAlign: 'center', }, - errorText: { - fontSize: 13, - color: '#FF3B30', - textAlign: 'center', - marginBottom: 16, + emptyText: { color: C.muted, fontSize: 13 }, + + strip: { gap: 10, paddingRight: 4 }, + entityChip: { + backgroundColor: C.surface, + borderWidth: 1, + borderColor: C.border, + borderRadius: 14, + paddingVertical: 11, + paddingHorizontal: 14, + minWidth: 124, + maxWidth: 220, + height: 76, + justifyContent: 'center', + gap: 7, }, - langChip: { - borderColor: '#C7C7CC', + entityChipHead: { flexDirection: 'row', alignItems: 'center', gap: 7 }, + dot: { width: 7, height: 7, borderRadius: 4 }, + entityType: { + fontSize: 11, + fontWeight: '700', + textTransform: 'uppercase', + letterSpacing: 0.6, }, - langChipActive: { - backgroundColor: '#007AFF', - borderColor: '#007AFF', + entityText: { color: C.text, fontSize: 15, fontWeight: '600' }, + + inputWrap: { + backgroundColor: C.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: C.border, + marginTop: 16, + }, + input: { + color: C.text, + fontSize: 16, + lineHeight: 22, + padding: 16, + minHeight: 56, + maxHeight: 130, + textAlignVertical: 'top', }, - resultsHeader: { - flexDirection: 'row', + + detectBtn: { + backgroundColor: C.accent, + borderRadius: 14, + paddingVertical: 15, alignItems: 'center', - gap: 8, - marginBottom: 8, + marginTop: 12, }, - card: { - backgroundColor: '#fff', - borderRadius: 12, - padding: 14, - marginBottom: 10, - borderLeftWidth: 4, + detectBtnDisabled: { opacity: 0.5 }, + detectBtnText: { color: '#fff', fontSize: 16, fontWeight: '700' }, + + backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.55)', justifyContent: 'flex-end' }, + sheet: { + backgroundColor: C.surfaceHi, + borderTopLeftRadius: 22, + borderTopRightRadius: 22, + borderWidth: 1, + borderColor: C.border, + paddingHorizontal: 16, + paddingTop: 18, + paddingBottom: 32, + }, + sheetTitle: { + color: C.muted, + fontSize: 12, + fontWeight: '700', + textTransform: 'uppercase', + letterSpacing: 1, + marginBottom: 6, + paddingHorizontal: 6, }, - cardHeader: { + sheetList: { maxHeight: 360 }, + langRow: { flexDirection: 'row', - alignItems: 'center', justifyContent: 'space-between', - marginBottom: 6, - }, - badge: { - paddingHorizontal: 10, - paddingVertical: 3, - borderRadius: 10, - }, - badgeText: { - color: '#fff', - fontSize: 12, - fontWeight: '600', - }, - range: { - fontSize: 12, - color: '#8E8E93', - fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', - }, - matchedText: { - fontSize: 15, - color: '#000', - fontWeight: '500', - }, - dataContainer: { - marginTop: 6, - paddingTop: 6, - borderTopWidth: StyleSheet.hairlineWidth, - borderTopColor: '#E5E5EA', - }, - dataText: { - fontSize: 13, - color: '#636366', - fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + alignItems: 'center', + paddingVertical: 14, + paddingHorizontal: 6, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: C.border, }, + langRowText: { color: C.text, fontSize: 16 }, + langRowActive: { color: C.accent, fontWeight: '700' }, + check: { color: C.accent, fontSize: 16, fontWeight: '700' }, }); From 9e6766ed00436503627a1c72bd48d0bea1430f8f Mon Sep 17 00:00:00 2001 From: Pablo Giraud-Carrier Date: Sat, 6 Jun 2026 14:13:27 +0200 Subject: [PATCH 4/7] example: full-bleed entity strip + lift input above gesture bar - Horizontal entity strip now spans edge-to-edge (negative margin cancels the body padding) with the padding re-applied inside the scroll content, so chips scroll to the screen edge while the first/last stay aligned with the UI. - More bottom padding so the input clears the Android gesture bar when the keyboard is closed. --- example/App.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/example/App.tsx b/example/App.tsx index 26275ea..2ffdcd4 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -187,6 +187,7 @@ export default function App() { @@ -279,7 +280,9 @@ const styles = StyleSheet.create({ flex: 1, paddingHorizontal: 20, paddingTop: Platform.OS === 'android' ? 44 : 12, - paddingBottom: 16, + // Extra bottom gap so the input clears the Android gesture bar / home indicator + // when the keyboard is closed. + paddingBottom: Platform.OS === 'android' ? 32 : 24, }, header: { marginBottom: 20 }, title: { fontSize: 30, fontWeight: '800', color: C.text, letterSpacing: -0.5 }, @@ -342,7 +345,10 @@ const styles = StyleSheet.create({ }, emptyText: { color: C.muted, fontSize: 13 }, - strip: { gap: 10, paddingRight: 4 }, + // Full-bleed: cancel the body's horizontal padding so chips scroll to the screen + // edge, then re-apply it inside the content so the first/last chips stay aligned. + stripScroll: { marginHorizontal: -20 }, + strip: { gap: 10, paddingHorizontal: 20 }, entityChip: { backgroundColor: C.surface, borderWidth: 1, From 452643d9eca9a852a9fc76b08417c7536191b0f6 Mon Sep 17 00:00:00 2001 From: Pablo Giraud-Carrier Date: Sat, 6 Jun 2026 15:13:10 +0200 Subject: [PATCH 5/7] example: split into components + migrate to react-native-safe-area-context - Reorganize the example into focused components (components/), with shared theme.ts and constants.ts; App.tsx is now just composition + the two hooks. - Replace the deprecated react-native SafeAreaView with SafeAreaProvider + useSafeAreaInsets (real device insets), removing the deprecation warning. Verified on Android: renders correctly with safe-area insets, live + on-tap detection both work. --- example/App.tsx | 424 ++++---------------------- example/components/DetectButton.tsx | 38 +++ example/components/DetectInput.tsx | 43 +++ example/components/DetectedList.tsx | 75 +++++ example/components/EntityChip.tsx | 40 +++ example/components/Header.tsx | 25 ++ example/components/LanguageButton.tsx | 36 +++ example/components/LanguageSheet.tsx | 72 +++++ example/components/ModeToggle.tsx | 49 +++ example/components/ModelStatus.tsx | 26 ++ example/constants.ts | 39 +++ example/metro.config.js | 14 +- example/package.json | 3 +- example/theme.ts | 13 + 14 files changed, 518 insertions(+), 379 deletions(-) create mode 100644 example/components/DetectButton.tsx create mode 100644 example/components/DetectInput.tsx create mode 100644 example/components/DetectedList.tsx create mode 100644 example/components/EntityChip.tsx create mode 100644 example/components/Header.tsx create mode 100644 example/components/LanguageButton.tsx create mode 100644 example/components/LanguageSheet.tsx create mode 100644 example/components/ModeToggle.tsx create mode 100644 example/components/ModelStatus.tsx create mode 100644 example/constants.ts create mode 100644 example/theme.ts diff --git a/example/App.tsx b/example/App.tsx index 2ffdcd4..a0c1306 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -1,76 +1,36 @@ import { StatusBar } from 'expo-status-bar'; import { useState } from 'react'; -import { - ActivityIndicator, - KeyboardAvoidingView, - Modal, - Platform, - Pressable, - SafeAreaView, - ScrollView, - StyleSheet, - Text, - TextInput, - View, -} from 'react-native'; +import { KeyboardAvoidingView, Platform, StyleSheet, View } from 'react-native'; import { useDataDetector, useDetectedEntities, type DetectedEntity, - type DetectionType, type ModelLanguage, } from 'react-native-data-detector'; +import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { DetectButton } from './components/DetectButton'; +import { DetectInput } from './components/DetectInput'; +import { DetectedList } from './components/DetectedList'; +import { Header } from './components/Header'; +import { LanguageButton } from './components/LanguageButton'; +import { LanguageSheet } from './components/LanguageSheet'; +import { ModeToggle, type Mode } from './components/ModeToggle'; +import { ModelStatus } from './components/ModelStatus'; +import { SAMPLE_TEXT } from './constants'; +import { C, SCREEN_PADDING } from './theme'; -const SAMPLE_TEXT = 'Call me at (555) 123-4567 or email john@example.com tomorrow at 9:30pm.'; - -// — Theme ————————————————————————————————————————————————————————————— -const C = { - bg: '#0B0C10', - surface: '#16181D', - surfaceHi: '#1E2128', - border: 'rgba(255,255,255,0.08)', - text: '#F4F4F6', - muted: '#8B8D98', - accent: '#6366F1', -}; - -const TYPE_COLORS: Record = { - phoneNumber: '#A78BFA', - link: '#60A5FA', - email: '#34D399', - address: '#FBBF24', - date: '#F472B6', -}; - -const TYPE_LABELS: Record = { - phoneNumber: 'Phone', - link: 'Link', - email: 'Email', - address: 'Address', - date: 'Date', -}; - -const LANGUAGES: { code: ModelLanguage; name: string }[] = [ - { code: 'ar', name: 'Arabic' }, - { code: 'nl', name: 'Dutch' }, - { code: 'en', name: 'English' }, - { code: 'fr', name: 'French' }, - { code: 'de', name: 'German' }, - { code: 'it', name: 'Italian' }, - { code: 'ja', name: 'Japanese' }, - { code: 'ko', name: 'Korean' }, - { code: 'pl', name: 'Polish' }, - { code: 'pt', name: 'Portuguese' }, - { code: 'ru', name: 'Russian' }, - { code: 'es', name: 'Spanish' }, - { code: 'th', name: 'Thai' }, - { code: 'tr', name: 'Turkish' }, - { code: 'zh', name: 'Chinese' }, -]; +export default function App() { + return ( + + + + ); +} -type Mode = 'reactive' | 'imperative'; +function DataDetectorScreen() { + const insets = useSafeAreaInsets(); -export default function App() { const [text, setText] = useState(SAMPLE_TEXT); const [language, setLanguage] = useState('en'); const [mode, setMode] = useState('reactive'); @@ -91,345 +51,67 @@ export default function App() { const entities = mode === 'reactive' ? liveEntities : tappedEntities; const busy = mode === 'reactive' ? isDetecting : detecting; + const isAndroid = Platform.OS === 'android'; const handleDetect = async () => { setDetecting(true); - try { - setTappedEntities(await detect(text)); - } catch { - setTappedEntities([]); - } finally { - setDetecting(false); - } + detect(text) + .then(setTappedEntities) + .catch(() => setTappedEntities([])) + .finally(() => setDetecting(false)); }; - const languageName = LANGUAGES.find((l) => l.code === language)?.name ?? language; - return ( - + - - - Data Detector - - {Platform.OS === 'ios' ? 'NSDataDetector' : 'ML Kit Entity Extraction'} - - + +
- {/* Compact controls: language dropdown + mode toggle */} - {Platform.OS === 'android' ? ( - setLangOpen(true)}> - {languageName} - - + {isAndroid ? ( + setLangOpen(true)} /> ) : ( )} - - - {( - [ - ['reactive', 'Live'], - ['imperative', 'On tap'], - ] as const - ).map(([value, label]) => { - const active = mode === value; - return ( - setMode(value)} - > - - {label} - - - ); - })} - + - {/* Subtle model status — only when not ready */} - {Platform.OS === 'android' && status !== 'ready' && ( - - {status === 'downloading' && } - - {status === 'downloading' - ? 'Downloading model…' - : status === 'error' - ? 'Model error' - : 'Model not downloaded'} - - - )} + {isAndroid && } - {/* Detected — horizontal, above the input so it stays clear of the keyboard */} - - Detected · {entities.length} - {busy && } - - - {entities.length === 0 ? ( - - - {status !== 'ready' && Platform.OS === 'android' - ? 'Preparing model…' - : mode === 'reactive' - ? 'Start typing to detect…' - : 'Tap Detect to analyze.'} - - - ) : ( - - {entities.map((entity, index) => { - const color = TYPE_COLORS[entity.type]; - return ( - - - - {TYPE_LABELS[entity.type]} - - - {entity.text} - - - ); - })} - - )} - - {/* Input */} - - - - + + {mode === 'imperative' && ( - - {detecting ? ( - - ) : ( - {isReady ? 'Detect' : 'Preparing model…'} - )} - + )} - {/* Language picker sheet */} - setLangOpen(false)} - > - setLangOpen(false)}> - {}}> - Language model - - {LANGUAGES.map((l) => { - const active = l.code === language; - return ( - { - setLanguage(l.code); - setLangOpen(false); - }} - > - - {l.name} - - {active && } - - ); - })} - - - - - + selected={language} + onSelect={(code) => { + setLanguage(code); + setLangOpen(false); + }} + onClose={() => setLangOpen(false)} + /> + ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: C.bg }, flex: { flex: 1 }, - body: { - flex: 1, - paddingHorizontal: 20, - paddingTop: Platform.OS === 'android' ? 44 : 12, - // Extra bottom gap so the input clears the Android gesture bar / home indicator - // when the keyboard is closed. - paddingBottom: Platform.OS === 'android' ? 32 : 24, - }, - header: { marginBottom: 20 }, - title: { fontSize: 30, fontWeight: '800', color: C.text, letterSpacing: -0.5 }, - subtitle: { - fontSize: 13, - color: C.muted, - marginTop: 2, - fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', - }, - + body: { flex: 1, paddingHorizontal: SCREEN_PADDING }, controls: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, - dropdown: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - backgroundColor: C.surface, - borderWidth: 1, - borderColor: C.border, - borderRadius: 999, - paddingVertical: 8, - paddingHorizontal: 14, - }, - dropdownText: { color: C.text, fontSize: 14, fontWeight: '600' }, - caret: { color: C.muted, fontSize: 11, marginTop: 1 }, - - toggle: { - flexDirection: 'row', - backgroundColor: C.surface, - borderRadius: 999, - borderWidth: 1, - borderColor: C.border, - padding: 3, - }, - toggleItem: { paddingVertical: 7, paddingHorizontal: 16, borderRadius: 999 }, - toggleItemActive: { backgroundColor: C.accent }, - toggleText: { color: C.muted, fontSize: 13, fontWeight: '600' }, - toggleTextActive: { color: '#fff' }, - - statusRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: 12 }, - statusText: { color: C.muted, fontSize: 13 }, - spacer: { flex: 1, minHeight: 16 }, - - detectedHeader: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 10 }, - label: { - fontSize: 12, - fontWeight: '700', - color: C.muted, - textTransform: 'uppercase', - letterSpacing: 1, - }, - emptyStrip: { - height: 76, - borderRadius: 14, - borderWidth: 1, - borderColor: C.border, - borderStyle: 'dashed', - alignItems: 'center', - justifyContent: 'center', - }, - emptyText: { color: C.muted, fontSize: 13 }, - - // Full-bleed: cancel the body's horizontal padding so chips scroll to the screen - // edge, then re-apply it inside the content so the first/last chips stay aligned. - stripScroll: { marginHorizontal: -20 }, - strip: { gap: 10, paddingHorizontal: 20 }, - entityChip: { - backgroundColor: C.surface, - borderWidth: 1, - borderColor: C.border, - borderRadius: 14, - paddingVertical: 11, - paddingHorizontal: 14, - minWidth: 124, - maxWidth: 220, - height: 76, - justifyContent: 'center', - gap: 7, - }, - entityChipHead: { flexDirection: 'row', alignItems: 'center', gap: 7 }, - dot: { width: 7, height: 7, borderRadius: 4 }, - entityType: { - fontSize: 11, - fontWeight: '700', - textTransform: 'uppercase', - letterSpacing: 0.6, - }, - entityText: { color: C.text, fontSize: 15, fontWeight: '600' }, - - inputWrap: { - backgroundColor: C.surface, - borderRadius: 16, - borderWidth: 1, - borderColor: C.border, - marginTop: 16, - }, - input: { - color: C.text, - fontSize: 16, - lineHeight: 22, - padding: 16, - minHeight: 56, - maxHeight: 130, - textAlignVertical: 'top', - }, - - detectBtn: { - backgroundColor: C.accent, - borderRadius: 14, - paddingVertical: 15, - alignItems: 'center', - marginTop: 12, - }, - detectBtnDisabled: { opacity: 0.5 }, - detectBtnText: { color: '#fff', fontSize: 16, fontWeight: '700' }, - - backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.55)', justifyContent: 'flex-end' }, - sheet: { - backgroundColor: C.surfaceHi, - borderTopLeftRadius: 22, - borderTopRightRadius: 22, - borderWidth: 1, - borderColor: C.border, - paddingHorizontal: 16, - paddingTop: 18, - paddingBottom: 32, - }, - sheetTitle: { - color: C.muted, - fontSize: 12, - fontWeight: '700', - textTransform: 'uppercase', - letterSpacing: 1, - marginBottom: 6, - paddingHorizontal: 6, - }, - sheetList: { maxHeight: 360 }, - langRow: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingVertical: 14, - paddingHorizontal: 6, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: C.border, - }, - langRowText: { color: C.text, fontSize: 16 }, - langRowActive: { color: C.accent, fontWeight: '700' }, - check: { color: C.accent, fontSize: 16, fontWeight: '700' }, }); diff --git a/example/components/DetectButton.tsx b/example/components/DetectButton.tsx new file mode 100644 index 0000000..a9a1d7f --- /dev/null +++ b/example/components/DetectButton.tsx @@ -0,0 +1,38 @@ +import { ActivityIndicator, Pressable, StyleSheet, Text } from 'react-native'; + +import { C } from '../theme'; + +interface Props { + detecting: boolean; + isReady: boolean; + onPress: () => void; +} + +export function DetectButton({ detecting, isReady, onPress }: Props) { + const disabled = detecting || !isReady; + return ( + + {detecting ? ( + + ) : ( + {isReady ? 'Detect' : 'Preparing model…'} + )} + + ); +} + +const styles = StyleSheet.create({ + button: { + backgroundColor: C.accent, + borderRadius: 14, + paddingVertical: 15, + alignItems: 'center', + marginTop: 12, + }, + disabled: { opacity: 0.5 }, + text: { color: '#fff', fontSize: 16, fontWeight: '700' }, +}); diff --git a/example/components/DetectInput.tsx b/example/components/DetectInput.tsx new file mode 100644 index 0000000..e30d2bd --- /dev/null +++ b/example/components/DetectInput.tsx @@ -0,0 +1,43 @@ +import { StyleSheet, TextInput, View } from 'react-native'; + +import { C } from '../theme'; + +interface Props { + value: string; + onChangeText: (text: string) => void; + placeholder?: string; +} + +export function DetectInput({ value, onChangeText, placeholder }: Props) { + return ( + + + + ); +} + +const styles = StyleSheet.create({ + wrap: { + backgroundColor: C.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: C.border, + marginTop: 16, + }, + input: { + color: C.text, + fontSize: 16, + lineHeight: 22, + padding: 16, + minHeight: 56, + maxHeight: 130, + textAlignVertical: 'top', + }, +}); diff --git a/example/components/DetectedList.tsx b/example/components/DetectedList.tsx new file mode 100644 index 0000000..b9cae50 --- /dev/null +++ b/example/components/DetectedList.tsx @@ -0,0 +1,75 @@ +import { ActivityIndicator, Platform, ScrollView, StyleSheet, Text, View } from 'react-native'; +import type { DetectedEntity, ModelStatus } from 'react-native-data-detector'; + +import { C, SCREEN_PADDING } from '../theme'; +import { EntityChip } from './EntityChip'; +import type { Mode } from './ModeToggle'; + +interface Props { + entities: DetectedEntity[]; + busy: boolean; + status: ModelStatus; + mode: Mode; +} + +export function DetectedList({ entities, busy, status, mode }: Props) { + const preparing = Platform.OS === 'android' && status !== 'ready'; + + return ( + + + Detected · {entities.length} + {busy && } + + + {entities.length === 0 ? ( + + + {preparing + ? 'Preparing model…' + : mode === 'reactive' + ? 'Start typing to detect…' + : 'Tap Detect to analyze.'} + + + ) : ( + + {entities.map((entity, index) => ( + + ))} + + )} + + ); +} + +const styles = StyleSheet.create({ + header: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 10 }, + label: { + fontSize: 12, + fontWeight: '700', + color: C.muted, + textTransform: 'uppercase', + letterSpacing: 1, + }, + empty: { + height: 76, + borderRadius: 14, + borderWidth: 1, + borderColor: C.border, + borderStyle: 'dashed', + alignItems: 'center', + justifyContent: 'center', + }, + emptyText: { color: C.muted, fontSize: 13 }, + // Full-bleed: cancel the screen padding so chips scroll to the edge, then + // re-apply it inside the content so the first/last chips stay aligned. + scroll: { marginHorizontal: -SCREEN_PADDING }, + strip: { gap: 10, paddingHorizontal: SCREEN_PADDING }, +}); diff --git a/example/components/EntityChip.tsx b/example/components/EntityChip.tsx new file mode 100644 index 0000000..a161250 --- /dev/null +++ b/example/components/EntityChip.tsx @@ -0,0 +1,40 @@ +import { StyleSheet, Text, View } from 'react-native'; +import type { DetectedEntity } from 'react-native-data-detector'; + +import { TYPE_COLORS, TYPE_LABELS } from '../constants'; +import { C } from '../theme'; + +export function EntityChip({ entity }: { entity: DetectedEntity }) { + const color = TYPE_COLORS[entity.type]; + return ( + + + + {TYPE_LABELS[entity.type]} + + + {entity.text} + + + ); +} + +const styles = StyleSheet.create({ + chip: { + backgroundColor: C.surface, + borderWidth: 1, + borderColor: C.border, + borderRadius: 14, + paddingVertical: 11, + paddingHorizontal: 14, + minWidth: 124, + maxWidth: 220, + height: 76, + justifyContent: 'center', + gap: 7, + }, + head: { flexDirection: 'row', alignItems: 'center', gap: 7 }, + dot: { width: 7, height: 7, borderRadius: 4 }, + type: { fontSize: 11, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 0.6 }, + text: { color: C.text, fontSize: 15, fontWeight: '600' }, +}); diff --git a/example/components/Header.tsx b/example/components/Header.tsx new file mode 100644 index 0000000..106bb70 --- /dev/null +++ b/example/components/Header.tsx @@ -0,0 +1,25 @@ +import { Platform, StyleSheet, Text, View } from 'react-native'; + +import { C } from '../theme'; + +export function Header() { + return ( + + Data Detector + + {Platform.OS === 'ios' ? 'NSDataDetector' : 'ML Kit Entity Extraction'} + + + ); +} + +const styles = StyleSheet.create({ + header: { marginBottom: 20 }, + title: { fontSize: 30, fontWeight: '800', color: C.text, letterSpacing: -0.5 }, + subtitle: { + fontSize: 13, + color: C.muted, + marginTop: 2, + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + }, +}); diff --git a/example/components/LanguageButton.tsx b/example/components/LanguageButton.tsx new file mode 100644 index 0000000..18360ce --- /dev/null +++ b/example/components/LanguageButton.tsx @@ -0,0 +1,36 @@ +import { Pressable, StyleSheet, Text } from 'react-native'; +import type { ModelLanguage } from 'react-native-data-detector'; + +import { LANGUAGES } from '../constants'; +import { C } from '../theme'; + +interface Props { + language: ModelLanguage; + onPress: () => void; +} + +export function LanguageButton({ language, onPress }: Props) { + const name = LANGUAGES.find((l) => l.code === language)?.name ?? language; + return ( + + {name} + + + ); +} + +const styles = StyleSheet.create({ + dropdown: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + backgroundColor: C.surface, + borderWidth: 1, + borderColor: C.border, + borderRadius: 999, + paddingVertical: 8, + paddingHorizontal: 14, + }, + text: { color: C.text, fontSize: 14, fontWeight: '600' }, + caret: { color: C.muted, fontSize: 11, marginTop: 1 }, +}); diff --git a/example/components/LanguageSheet.tsx b/example/components/LanguageSheet.tsx new file mode 100644 index 0000000..aed9075 --- /dev/null +++ b/example/components/LanguageSheet.tsx @@ -0,0 +1,72 @@ +import { Modal, Pressable, ScrollView, StyleSheet, Text } from 'react-native'; +import type { ModelLanguage } from 'react-native-data-detector'; + +import { LANGUAGES } from '../constants'; +import { C } from '../theme'; + +interface Props { + visible: boolean; + selected: ModelLanguage; + onSelect: (code: ModelLanguage) => void; + onClose: () => void; +} + +export function LanguageSheet({ visible, selected, onSelect, onClose }: Props) { + return ( + + + {/* Absorb taps so they don't fall through to the backdrop. */} + {}}> + Language model + + {LANGUAGES.map((l) => { + const active = l.code === selected; + return ( + onSelect(l.code)}> + {l.name} + {active && } + + ); + })} + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.55)', justifyContent: 'flex-end' }, + sheet: { + backgroundColor: C.surfaceHi, + borderTopLeftRadius: 22, + borderTopRightRadius: 22, + borderWidth: 1, + borderColor: C.border, + paddingHorizontal: 16, + paddingTop: 18, + paddingBottom: 32, + }, + title: { + color: C.muted, + fontSize: 12, + fontWeight: '700', + textTransform: 'uppercase', + letterSpacing: 1, + marginBottom: 6, + paddingHorizontal: 6, + }, + list: { maxHeight: 360 }, + row: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 14, + paddingHorizontal: 6, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: C.border, + }, + rowText: { color: C.text, fontSize: 16 }, + rowActive: { color: C.accent, fontWeight: '700' }, + check: { color: C.accent, fontSize: 16, fontWeight: '700' }, +}); diff --git a/example/components/ModeToggle.tsx b/example/components/ModeToggle.tsx new file mode 100644 index 0000000..3148952 --- /dev/null +++ b/example/components/ModeToggle.tsx @@ -0,0 +1,49 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { C } from '../theme'; + +export type Mode = 'reactive' | 'imperative'; + +const OPTIONS: [Mode, string][] = [ + ['reactive', 'Live'], + ['imperative', 'On tap'], +]; + +interface Props { + mode: Mode; + onChange: (mode: Mode) => void; +} + +export function ModeToggle({ mode, onChange }: Props) { + return ( + + {OPTIONS.map(([value, label]) => { + const active = mode === value; + return ( + onChange(value)} + > + {label} + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + toggle: { + flexDirection: 'row', + backgroundColor: C.surface, + borderRadius: 999, + borderWidth: 1, + borderColor: C.border, + padding: 3, + }, + item: { paddingVertical: 7, paddingHorizontal: 16, borderRadius: 999 }, + itemActive: { backgroundColor: C.accent }, + text: { color: C.muted, fontSize: 13, fontWeight: '600' }, + textActive: { color: '#fff' }, +}); diff --git a/example/components/ModelStatus.tsx b/example/components/ModelStatus.tsx new file mode 100644 index 0000000..bc79e5d --- /dev/null +++ b/example/components/ModelStatus.tsx @@ -0,0 +1,26 @@ +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; +import type { ModelStatus as Status } from 'react-native-data-detector'; + +import { C } from '../theme'; + +const LABELS: Record = { + downloading: 'Downloading model…', + error: 'Model error', + notDownloaded: 'Model not downloaded', +}; + +/** Renders a subtle status line — nothing when the model is ready. */ +export function ModelStatus({ status }: { status: Status }) { + if (status === 'ready') return null; + return ( + + {status === 'downloading' && } + {LABELS[status] ?? status} + + ); +} + +const styles = StyleSheet.create({ + row: { flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: 12 }, + text: { color: C.muted, fontSize: 13 }, +}); diff --git a/example/constants.ts b/example/constants.ts new file mode 100644 index 0000000..c785756 --- /dev/null +++ b/example/constants.ts @@ -0,0 +1,39 @@ +import type { DetectionType, ModelLanguage } from 'react-native-data-detector'; + +export const SAMPLE_TEXT = + 'Call me at (555) 123-4567 or email john@example.com tomorrow at 9:30pm.'; + +export const TYPE_COLORS: Record = { + phoneNumber: '#A78BFA', + link: '#60A5FA', + email: '#34D399', + address: '#FBBF24', + date: '#F472B6', +}; + +export const TYPE_LABELS: Record = { + phoneNumber: 'Phone', + link: 'Link', + email: 'Email', + address: 'Address', + date: 'Date', +}; + +/** The 15 language models supported by ML Kit Entity Extraction (Android). */ +export const LANGUAGES: { code: ModelLanguage; name: string }[] = [ + { code: 'ar', name: 'Arabic' }, + { code: 'nl', name: 'Dutch' }, + { code: 'en', name: 'English' }, + { code: 'fr', name: 'French' }, + { code: 'de', name: 'German' }, + { code: 'it', name: 'Italian' }, + { code: 'ja', name: 'Japanese' }, + { code: 'ko', name: 'Korean' }, + { code: 'pl', name: 'Polish' }, + { code: 'pt', name: 'Portuguese' }, + { code: 'ru', name: 'Russian' }, + { code: 'es', name: 'Spanish' }, + { code: 'th', name: 'Thai' }, + { code: 'tr', name: 'Turkish' }, + { code: 'zh', name: 'Chinese' }, +]; diff --git a/example/metro.config.js b/example/metro.config.js index 6e70bda..4f596ec 100644 --- a/example/metro.config.js +++ b/example/metro.config.js @@ -6,20 +6,20 @@ const monorepoRoot = path.resolve(projectRoot, '..'); const config = getDefaultConfig(projectRoot); -// Watch the parent directory so Metro can resolve the file:.. dependency +// Watch the repo root so Metro picks up changes to the linked +// `react-native-data-detector` package (a `file:..` dependency). config.watchFolders = [monorepoRoot]; -// Ensure Metro resolves from the example's node_modules first +// Resolve from the example's node_modules first, then the repo root. config.resolver.nodeModulesPaths = [ path.resolve(projectRoot, 'node_modules'), path.resolve(monorepoRoot, 'node_modules'), ]; -// The monorepo root also carries its own (newer) react / react-native, pulled in -// by npm auto-installing the library's peerDependencies. Pin the singletons to the -// example's copies so Metro and React Native codegen never mix versions — mixing -// breaks native component codegen (e.g. "Unable to determine event arguments" in -// VirtualViewNativeComponent). +// The repo root carries its own, newer react / react-native (pulled in by the +// library's peer + test dependencies). Pin the singletons to the example's copies +// and block the root ones so Metro never mixes versions — mixing causes crashes +// like "Property 'MessageQueue' doesn't exist". config.resolver.extraNodeModules = { react: path.resolve(projectRoot, 'node_modules/react'), 'react-native': path.resolve(projectRoot, 'node_modules/react-native'), diff --git a/example/package.json b/example/package.json index 840faa2..42a841c 100644 --- a/example/package.json +++ b/example/package.json @@ -14,7 +14,8 @@ "expo-status-bar": "~3.0.9", "react": "19.1.0", "react-native": "0.81.5", - "react-native-data-detector": "file:.." + "react-native-data-detector": "file:..", + "react-native-safe-area-context": "~5.6.0" }, "devDependencies": { "@types/react": "~19.1.0", diff --git a/example/theme.ts b/example/theme.ts new file mode 100644 index 0000000..7a58156 --- /dev/null +++ b/example/theme.ts @@ -0,0 +1,13 @@ +/** Shared visual tokens for the example app. */ +export const C = { + bg: '#0B0C10', + surface: '#16181D', + surfaceHi: '#1E2128', + border: 'rgba(255,255,255,0.08)', + text: '#F4F4F6', + muted: '#8B8D98', + accent: '#6366F1', +} as const; + +/** Horizontal screen inset. Shared so full-bleed rows can cancel it precisely. */ +export const SCREEN_PADDING = 20; From 35918c27a2275e48fe972af0f23546907c0eef5c Mon Sep 17 00:00:00 2001 From: Pablo Giraud-Carrier Date: Sat, 6 Jun 2026 15:37:16 +0200 Subject: [PATCH 6/7] refactor(useDetectedEntities): tidy hook internals Group state declarations, derive typesKey inline, and read types directly in the detection effect. No behavior change (typesKey still gates re-runs; latest run wins). --- src/useDetectedEntities.ts | 47 ++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/src/useDetectedEntities.ts b/src/useDetectedEntities.ts index 85228b2..2e60356 100644 --- a/src/useDetectedEntities.ts +++ b/src/useDetectedEntities.ts @@ -53,58 +53,61 @@ export function useDetectedEntities( text: string, options?: UseDetectedEntitiesOptions, ): UseDetectedEntitiesResult { + const [entities, setEntities] = useState([]); + const [isDetecting, setIsDetecting] = useState(false); + const [detectError, setDetectError] = useState(null); + const [debouncedText, setDebouncedText] = useState(text); + const debounceMs = options?.debounceMs ?? 300; const language = options?.language ?? DEFAULT_LANGUAGE; const enabled = options?.enabled ?? true; const autoPrepare = options?.autoPrepare ?? true; + const types = options?.types; + const typesKey = types?.join(',') ?? ''; const { status, isReady, error: modelError } = useModelLifecycle(language, autoPrepare); - const [entities, setEntities] = useState([]); - const [isDetecting, setIsDetecting] = useState(false); - const [detectError, setDetectError] = useState(null); + const runId = useRef(0); - // Debounce the incoming text. - const [debouncedText, setDebouncedText] = useState(text); useEffect(() => { - if (!enabled) return; + if (!enabled) { + return; + } const id = setTimeout(() => setDebouncedText(text), debounceMs); + return () => clearTimeout(id); }, [text, debounceMs, enabled]); - // `types` is an array; depend on a stable string key and read the latest value - // from a ref so a new array identity each render doesn't re-trigger detection. - const typesKey = types ? types.join(',') : ''; - const typesRef = useRef(types); - typesRef.current = types; - - // Monotonic id so only the most recent detection can commit its result. - const runId = useRef(0); - useEffect(() => { - if (!enabled || !isReady) return; + if (!enabled || !isReady) { + return; + } if (!debouncedText) { - runId.current += 1; // invalidate any in-flight detection + runId.current += 1; setEntities([]); setIsDetecting(false); setDetectError(null); return; } - const myRun = ++runId.current; + const currentRunId = ++runId.current; + setIsDetecting(true); setDetectError(null); - - detectFn(debouncedText, { types: typesRef.current, language }) + detectFn(debouncedText, { types, language }) .then((res) => { - if (myRun !== runId.current) return; // a newer run superseded this one + if (currentRunId !== runId.current) { + return; + } setEntities(res); setIsDetecting(false); }) .catch((e) => { - if (myRun !== runId.current) return; + if (currentRunId !== runId.current) { + return; + } setDetectError(e instanceof Error ? e : new Error(String(e))); setIsDetecting(false); }); From 1208486af964afb1fe6784b2adb29b971e292021 Mon Sep 17 00:00:00 2001 From: Pablo Giraud-Carrier Date: Sat, 6 Jun 2026 15:37:16 +0200 Subject: [PATCH 7/7] example: upgrade to Expo SDK 56 and move screen into screens/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Upgrade example to Expo SDK 56 (react-native 0.85.3, react 19.2.3, react-native-safe-area-context 5.7.0) — aligns with the repo root's RN line and adds iOS 26 support, fixing the simulator runtime errors. - Extract the screen into screens/DataDetector.tsx; App.tsx is a thin wrapper. - Component style/formatting cleanups. --- example/App.tsx | 112 +---------------------- example/app.json | 5 +- example/components/DetectButton.tsx | 11 ++- example/components/DetectedList.tsx | 18 ++-- example/components/EntityChip.tsx | 32 +++++-- example/components/Header.tsx | 11 ++- example/components/LanguageButton.tsx | 13 ++- example/components/LanguageSheet.tsx | 38 ++++++-- example/components/ModeToggle.tsx | 21 ++++- example/components/ModelStatus.tsx | 24 +++-- example/package.json | 16 ++-- example/screens/DataDetector.tsx | 124 ++++++++++++++++++++++++++ 12 files changed, 271 insertions(+), 154 deletions(-) create mode 100644 example/screens/DataDetector.tsx diff --git a/example/App.tsx b/example/App.tsx index a0c1306..4152289 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -1,117 +1,11 @@ -import { StatusBar } from 'expo-status-bar'; -import { useState } from 'react'; -import { KeyboardAvoidingView, Platform, StyleSheet, View } from 'react-native'; -import { - useDataDetector, - useDetectedEntities, - type DetectedEntity, - type ModelLanguage, -} from 'react-native-data-detector'; -import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; -import { DetectButton } from './components/DetectButton'; -import { DetectInput } from './components/DetectInput'; -import { DetectedList } from './components/DetectedList'; -import { Header } from './components/Header'; -import { LanguageButton } from './components/LanguageButton'; -import { LanguageSheet } from './components/LanguageSheet'; -import { ModeToggle, type Mode } from './components/ModeToggle'; -import { ModelStatus } from './components/ModelStatus'; -import { SAMPLE_TEXT } from './constants'; -import { C, SCREEN_PADDING } from './theme'; +import DataDetector from './screens/DataDetector'; export default function App() { return ( - + ); } - -function DataDetectorScreen() { - const insets = useSafeAreaInsets(); - - const [text, setText] = useState(SAMPLE_TEXT); - const [language, setLanguage] = useState('en'); - const [mode, setMode] = useState('reactive'); - const [langOpen, setLangOpen] = useState(false); - - // Imperative hook: model lifecycle + a detect() you call yourself. - const { detect, status, isReady } = useDataDetector({ language }); - - // Reactive hook: debounced detection as `text` changes (paused in "On tap" mode). - const { entities: liveEntities, isDetecting } = useDetectedEntities(text, { - language, - debounceMs: 200, - enabled: mode === 'reactive', - }); - - const [tappedEntities, setTappedEntities] = useState([]); - const [detecting, setDetecting] = useState(false); - - const entities = mode === 'reactive' ? liveEntities : tappedEntities; - const busy = mode === 'reactive' ? isDetecting : detecting; - const isAndroid = Platform.OS === 'android'; - - const handleDetect = async () => { - setDetecting(true); - detect(text) - .then(setTappedEntities) - .catch(() => setTappedEntities([])) - .finally(() => setDetecting(false)); - }; - - return ( - - - - -
- - - {isAndroid ? ( - setLangOpen(true)} /> - ) : ( - - )} - - - - {isAndroid && } - - - - - - {mode === 'imperative' && ( - - )} - - - - { - setLanguage(code); - setLangOpen(false); - }} - onClose={() => setLangOpen(false)} - /> - - ); -} - -const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: C.bg }, - flex: { flex: 1 }, - body: { flex: 1, paddingHorizontal: SCREEN_PADDING }, - controls: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, - spacer: { flex: 1, minHeight: 16 }, -}); diff --git a/example/app.json b/example/app.json index 79e3c7e..d62ed69 100644 --- a/example/app.json +++ b/example/app.json @@ -5,7 +5,6 @@ "version": "1.0.0", "orientation": "portrait", "icon": "./assets/icon.png", - "userInterfaceStyle": "light", "newArchEnabled": true, "splash": { "image": "./assets/splash-icon.png", @@ -21,7 +20,6 @@ "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#ffffff" }, - "edgeToEdgeEnabled": true, "package": "com.example.datadetector" }, "web": { @@ -35,7 +33,8 @@ "minSdkVersion": 26 } } - ] + ], + "expo-status-bar" ] } } diff --git a/example/components/DetectButton.tsx b/example/components/DetectButton.tsx index a9a1d7f..bbfa796 100644 --- a/example/components/DetectButton.tsx +++ b/example/components/DetectButton.tsx @@ -10,6 +10,7 @@ interface Props { export function DetectButton({ detecting, isReady, onPress }: Props) { const disabled = detecting || !isReady; + return ( @@ -33,8 +38,25 @@ const styles = StyleSheet.create({ justifyContent: 'center', gap: 7, }, - head: { flexDirection: 'row', alignItems: 'center', gap: 7 }, - dot: { width: 7, height: 7, borderRadius: 4 }, - type: { fontSize: 11, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 0.6 }, - text: { color: C.text, fontSize: 15, fontWeight: '600' }, + head: { + flexDirection: 'row', + alignItems: 'center', + gap: 7, + }, + dot: { + width: 7, + height: 7, + borderRadius: 4, + }, + type: { + fontSize: 11, + fontWeight: '700', + textTransform: 'uppercase', + letterSpacing: 0.6, + }, + text: { + color: C.text, + fontSize: 15, + fontWeight: '600', + }, }); diff --git a/example/components/Header.tsx b/example/components/Header.tsx index 106bb70..abb20b2 100644 --- a/example/components/Header.tsx +++ b/example/components/Header.tsx @@ -14,8 +14,15 @@ export function Header() { } const styles = StyleSheet.create({ - header: { marginBottom: 20 }, - title: { fontSize: 30, fontWeight: '800', color: C.text, letterSpacing: -0.5 }, + header: { + marginBottom: 20, + }, + title: { + fontSize: 30, + fontWeight: '800', + color: C.text, + letterSpacing: -0.5, + }, subtitle: { fontSize: 13, color: C.muted, diff --git a/example/components/LanguageButton.tsx b/example/components/LanguageButton.tsx index 18360ce..ae7ace7 100644 --- a/example/components/LanguageButton.tsx +++ b/example/components/LanguageButton.tsx @@ -11,6 +11,7 @@ interface Props { export function LanguageButton({ language, onPress }: Props) { const name = LANGUAGES.find((l) => l.code === language)?.name ?? language; + return ( {name} @@ -31,6 +32,14 @@ const styles = StyleSheet.create({ paddingVertical: 8, paddingHorizontal: 14, }, - text: { color: C.text, fontSize: 14, fontWeight: '600' }, - caret: { color: C.muted, fontSize: 11, marginTop: 1 }, + text: { + color: C.text, + fontSize: 14, + fontWeight: '600', + }, + caret: { + color: C.muted, + fontSize: 11, + marginTop: 1, + }, }); diff --git a/example/components/LanguageSheet.tsx b/example/components/LanguageSheet.tsx index aed9075..8ddff70 100644 --- a/example/components/LanguageSheet.tsx +++ b/example/components/LanguageSheet.tsx @@ -15,12 +15,12 @@ export function LanguageSheet({ visible, selected, onSelect, onClose }: Props) { return ( - {/* Absorb taps so they don't fall through to the backdrop. */} - {}}> + Language model - + {LANGUAGES.map((l) => { const active = l.code === selected; + return ( onSelect(l.code)}> {l.name} @@ -36,14 +36,18 @@ export function LanguageSheet({ visible, selected, onSelect, onClose }: Props) { } const styles = StyleSheet.create({ - backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.55)', justifyContent: 'flex-end' }, + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.55)', + justifyContent: 'flex-end', + }, sheet: { backgroundColor: C.surfaceHi, borderTopLeftRadius: 22, borderTopRightRadius: 22, borderWidth: 1, borderColor: C.border, - paddingHorizontal: 16, + paddingHorizontal: 4, paddingTop: 18, paddingBottom: 32, }, @@ -56,7 +60,12 @@ const styles = StyleSheet.create({ marginBottom: 6, paddingHorizontal: 6, }, - list: { maxHeight: 360 }, + list: { + maxHeight: 360, + }, + listContent: { + paddingHorizontal: 12, + }, row: { flexDirection: 'row', justifyContent: 'space-between', @@ -66,7 +75,18 @@ const styles = StyleSheet.create({ borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: C.border, }, - rowText: { color: C.text, fontSize: 16 }, - rowActive: { color: C.accent, fontWeight: '700' }, - check: { color: C.accent, fontSize: 16, fontWeight: '700' }, + rowText: { + color: C.text, + fontSize: 16, + fontWeight: '700', + }, + rowActive: { + color: C.accent, + fontWeight: '700', + }, + check: { + color: C.accent, + fontSize: 16, + fontWeight: '700', + }, }); diff --git a/example/components/ModeToggle.tsx b/example/components/ModeToggle.tsx index 3148952..b8f45a2 100644 --- a/example/components/ModeToggle.tsx +++ b/example/components/ModeToggle.tsx @@ -19,6 +19,7 @@ export function ModeToggle({ mode, onChange }: Props) { {OPTIONS.map(([value, label]) => { const active = mode === value; + return ( = { notDownloaded: 'Model not downloaded', }; -/** Renders a subtle status line — nothing when the model is ready. */ -export function ModelStatus({ status }: { status: Status }) { - if (status === 'ready') return null; +interface Props { + status: Status; +} + +export function ModelStatus({ status }: Props) { + if (status === 'ready') { + return null; + } + return ( {status === 'downloading' && } @@ -21,6 +27,14 @@ export function ModelStatus({ status }: { status: Status }) { } const styles = StyleSheet.create({ - row: { flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: 12 }, - text: { color: C.muted, fontSize: 13 }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginTop: 12, + }, + text: { + color: C.muted, + fontSize: 13, + }, }); diff --git a/example/package.json b/example/package.json index 42a841c..3c0dab6 100644 --- a/example/package.json +++ b/example/package.json @@ -9,17 +9,17 @@ "web": "expo start --web" }, "dependencies": { - "expo": "~54.0.33", - "expo-build-properties": "~1.0.10", - "expo-status-bar": "~3.0.9", - "react": "19.1.0", - "react-native": "0.81.5", + "expo": "^56.0.9", + "expo-build-properties": "~56.0.17", + "expo-status-bar": "~56.0.4", + "react": "19.2.3", + "react-native": "0.85.3", "react-native-data-detector": "file:..", - "react-native-safe-area-context": "~5.6.0" + "react-native-safe-area-context": "~5.7.0" }, "devDependencies": { - "@types/react": "~19.1.0", - "typescript": "~5.9.2" + "@types/react": "~19.2.14", + "typescript": "~6.0.3" }, "private": true } diff --git a/example/screens/DataDetector.tsx b/example/screens/DataDetector.tsx new file mode 100644 index 0000000..afd179a --- /dev/null +++ b/example/screens/DataDetector.tsx @@ -0,0 +1,124 @@ +import { StatusBar } from 'expo-status-bar'; +import { useState } from 'react'; +import { KeyboardAvoidingView, Platform, StyleSheet, View } from 'react-native'; +import { + useDataDetector, + useDetectedEntities, + type DetectedEntity, + type ModelLanguage, +} from 'react-native-data-detector'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { DetectButton } from '../components/DetectButton'; +import { DetectInput } from '../components/DetectInput'; +import { DetectedList } from '../components/DetectedList'; +import { Header } from '../components/Header'; +import { LanguageButton } from '../components/LanguageButton'; +import { LanguageSheet } from '../components/LanguageSheet'; +import { ModeToggle, type Mode } from '../components/ModeToggle'; +import { ModelStatus } from '../components/ModelStatus'; +import { SAMPLE_TEXT } from '../constants'; +import { C, SCREEN_PADDING } from '../theme'; + +export default function DataDetector() { + const insets = useSafeAreaInsets(); + + const [text, setText] = useState(SAMPLE_TEXT); + const [language, setLanguage] = useState('en'); + const [mode, setMode] = useState('reactive'); + const [langOpen, setLangOpen] = useState(false); + + // Imperative hook: model lifecycle + a detect() you call yourself. + const { detect, status, isReady } = useDataDetector({ language }); + + // Reactive hook: debounced detection as `text` changes (paused in "On tap" mode). + const { entities: liveEntities, isDetecting } = useDetectedEntities(text, { + language, + debounceMs: 200, + enabled: mode === 'reactive', + }); + + const [tappedEntities, setTappedEntities] = useState([]); + const [detecting, setDetecting] = useState(false); + + const entities = mode === 'reactive' ? liveEntities : tappedEntities; + const busy = mode === 'reactive' ? isDetecting : detecting; + const isAndroid = Platform.OS === 'android'; + + const handleDetect = async () => { + setDetecting(true); + detect(text) + .then(setTappedEntities) + .catch(() => setTappedEntities([])) + .finally(() => setDetecting(false)); + }; + + return ( + + + + +
+ + + {isAndroid ? ( + setLangOpen(true)} /> + ) : ( + + )} + + + + {isAndroid && } + + + + + + {mode === 'imperative' && ( + + )} + + + + { + setLanguage(code); + setLangOpen(false); + }} + onClose={() => setLangOpen(false)} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: C.bg, + }, + flex: { + flex: 1, + }, + body: { + flex: 1, + paddingHorizontal: SCREEN_PADDING, + }, + controls: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + spacer: { + flex: 1, + minHeight: 16, + }, +});