Skip to content

Commit 2a7ae95

Browse files
authored
feat(i18n): localize the app into 13 languages (library, onboarding, backup, sheets) (#18)
Adds a typed i18n module: src/i18n with an English master catalog (Strings = typeof en) and 12 full translations - de, fr, es, it, pt, ja, ko, nl, ru, zh-Hans, zh-Hant - each typed as Strings so the compiler rejects any missing key or wrong parameter arity. Parameterized strings are plain functions (correct pluralization per language, including Russian three-form plurals; CJK languages drop the singular branch). Locale resolution uses expo-localization once at startup, with a lazy require so a missing native module degrades to English instead of crashing. The Chinese branch resolves by script code first (region is only a fallback via languageRegionCode) so a Simplified-Chinese user with a TW/HK/MO device region is not misrouted to Traditional. All user-facing strings outside the editor now come from the catalog: library and folder screens, note/folder menus and dialogs, onboarding (including the backup slide), backup status lines and alerts, support and community sheets, and relative timestamps. The editor screen is the follow-up PR. Verified on iPad simulator with device language set to German (native rebuild with the ExpoLocalization pod); typecheck and all 43 tests pass; Metro export bundles. Claude-Session: https://claude.ai/code/session_01YM3PBapEXHwuXY97Bibsqk
1 parent a935dcd commit 2a7ae95

31 files changed

Lines changed: 2071 additions & 157 deletions

app/folder/[id].tsx

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { useCallback, useEffect, useState } from 'react';
22
import { ActivityIndicator, Alert, ScrollView, StyleSheet, View } from 'react-native';
33
import { SafeAreaView } from 'react-native-safe-area-context';
4+
import { t } from '../../src/i18n';
45
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router';
56
import * as Haptics from 'expo-haptics';
67
import { LibraryHeader } from '../../src/components/library/LibraryHeader';
@@ -91,28 +92,28 @@ export default function FolderScreen() {
9192
}
9293
} catch (error) {
9394
if (__DEV__) console.warn('[FolderScreen] create note failed', error);
94-
Alert.alert('Could not create note', 'Please try again.');
95+
Alert.alert(t.library.couldNotCreateNote, t.common.pleaseTryAgain);
9596
} finally {
9697
creatingNoteRef.current = false;
9798
}
9899
}, [folder, router]);
99100

100101
const confirmDeleteNote = useCallback((note: NoteMetadata) => {
101102
Alert.alert(
102-
'Delete note?',
103-
`"${note.title}" will be permanently deleted.`,
103+
t.library.deleteNoteTitle,
104+
t.library.deleteNoteMessage(note.title),
104105
[
105-
{ text: 'Cancel', style: 'cancel' },
106+
{ text: t.common.cancel, style: 'cancel' },
106107
{
107-
text: 'Delete',
108+
text: t.common.delete,
108109
style: 'destructive',
109110
onPress: async () => {
110111
try {
111112
await deleteNote(note.id);
112113
await refresh();
113114
} catch (error) {
114115
if (__DEV__) console.warn('[FolderScreen] delete note failed', error);
115-
Alert.alert('Could not delete note', 'Please try again.');
116+
Alert.alert(t.library.couldNotDeleteNote, t.common.pleaseTryAgain);
116117
}
117118
},
118119
},
@@ -127,7 +128,7 @@ export default function FolderScreen() {
127128
style={[styles.flex, { backgroundColor: theme.colors.background }]}
128129
>
129130
<LibraryHeader
130-
title="Folder"
131+
title={t.common.folder}
131132
showBack
132133
onBack={() => router.replace('/')}
133134
/>
@@ -141,7 +142,7 @@ export default function FolderScreen() {
141142
return (
142143
<SafeAreaView edges={['top']} style={[styles.flex, { backgroundColor: theme.colors.background }]}>
143144
<LibraryHeader
144-
title={folder?.name ?? 'Folder'}
145+
title={folder?.name ?? t.common.folder}
145146
showBack
146147
onBack={() => router.replace('/')}
147148
rightIcon="create-outline"
@@ -150,8 +151,8 @@ export default function FolderScreen() {
150151

151152
{notes.length === 0 ? (
152153
<EmptyState
153-
title="Empty folder"
154-
subtitle={`Tap the plus button to add a note to ${folder?.name ?? 'this folder'}.`}
154+
title={t.library.emptyFolderTitle}
155+
subtitle={t.library.emptyFolderSubtitle(folder?.name ?? t.library.thisFolder)}
155156
iconName="folder-open-outline"
156157
/>
157158
) : (
@@ -194,25 +195,25 @@ export default function FolderScreen() {
194195
? [
195196
{
196197
key: 'rename',
197-
label: 'Rename',
198+
label: t.common.rename,
198199
onPress: () => setAction({ kind: 'renameNote', note: action.note }),
199200
},
200201
{
201202
key: 'moveRoot',
202-
label: 'Move to Root',
203+
label: t.library.moveToRoot,
203204
onPress: async () => {
204205
await moveNote(action.note.id, null);
205206
await refresh();
206207
},
207208
},
208209
{
209210
key: 'move',
210-
label: 'Move to Folder',
211+
label: t.library.moveToFolder,
211212
onPress: () => setAction({ kind: 'moveNote', note: action.note }),
212213
},
213214
{
214215
key: 'delete',
215-
label: 'Delete',
216+
label: t.common.delete,
216217
destructive: true,
217218
onPress: () => confirmDeleteNote(action.note),
218219
},
@@ -224,9 +225,9 @@ export default function FolderScreen() {
224225

225226
<RenameDialog
226227
visible={action?.kind === 'renameNote'}
227-
title="Rename note"
228+
title={t.library.renameNoteTitle}
228229
initialValue={action?.kind === 'renameNote' ? action.note.title : ''}
229-
placeholder="Note title"
230+
placeholder={t.library.noteTitlePlaceholder}
230231
onCancel={() => setAction(null)}
231232
onConfirm={async (value) => {
232233
if (action?.kind === 'renameNote') {
@@ -239,9 +240,9 @@ export default function FolderScreen() {
239240

240241
<RenameDialog
241242
visible={action?.kind === 'renameFolder'}
242-
title="Rename folder"
243+
title={t.library.renameFolderTitle}
243244
initialValue={folder?.name ?? ''}
244-
placeholder="Folder name"
245+
placeholder={t.library.folderNamePlaceholder}
245246
onCancel={() => setAction(null)}
246247
onConfirm={async (value) => {
247248
if (folder) {

app/index.tsx

Lines changed: 38 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
renameFolder,
4343
} from '../src/services/foldersRepo';
4444
import type { BackgroundType, FolderMetadata, NoteMetadata } from '../src/types/note';
45+
import { t } from '../src/i18n';
4546

4647
type Action =
4748
| { kind: 'newItem' }
@@ -160,7 +161,7 @@ export default function LibraryScreen() {
160161
}
161162
} catch (error) {
162163
if (__DEV__) console.warn('[LibraryScreen] create note failed', error);
163-
Alert.alert('Could not create note', 'Please try again.');
164+
Alert.alert(t.library.couldNotCreateNote, t.common.pleaseTryAgain);
164165
} finally {
165166
creatingNoteRef.current = false;
166167
}
@@ -172,20 +173,20 @@ export default function LibraryScreen() {
172173

173174
const confirmDeleteNote = useCallback((note: NoteMetadata) => {
174175
Alert.alert(
175-
'Delete note?',
176-
`"${note.title}" will be permanently deleted.`,
176+
t.library.deleteNoteTitle,
177+
t.library.deleteNoteMessage(note.title),
177178
[
178-
{ text: 'Cancel', style: 'cancel' },
179+
{ text: t.common.cancel, style: 'cancel' },
179180
{
180-
text: 'Delete',
181+
text: t.common.delete,
181182
style: 'destructive',
182183
onPress: async () => {
183184
try {
184185
await deleteNote(note.id);
185186
await refresh();
186187
} catch (error) {
187188
if (__DEV__) console.warn('[LibraryScreen] delete note failed', error);
188-
Alert.alert('Could not delete note', 'Please try again.');
189+
Alert.alert(t.library.couldNotDeleteNote, t.common.pleaseTryAgain);
189190
}
190191
},
191192
},
@@ -195,20 +196,20 @@ export default function LibraryScreen() {
195196

196197
const confirmDeleteFolderKeepNotes = useCallback((folder: FolderMetadata) => {
197198
Alert.alert(
198-
'Delete folder?',
199-
`"${folder.name}" will be deleted. Its notes will stay in your library.`,
199+
t.library.deleteFolderTitle,
200+
t.library.deleteFolderMessage(folder.name),
200201
[
201-
{ text: 'Cancel', style: 'cancel' },
202+
{ text: t.common.cancel, style: 'cancel' },
202203
{
203-
text: 'Delete Folder',
204+
text: t.library.deleteFolderLabel,
204205
style: 'destructive',
205206
onPress: async () => {
206207
try {
207208
await deleteFolder(folder.id, 'orphan-notes');
208209
await refresh();
209210
} catch (error) {
210211
if (__DEV__) console.warn('[LibraryScreen] delete folder failed', error);
211-
Alert.alert('Could not delete folder', 'Please try again.');
212+
Alert.alert(t.library.couldNotDeleteFolder, t.common.pleaseTryAgain);
212213
}
213214
},
214215
},
@@ -217,22 +218,21 @@ export default function LibraryScreen() {
217218
}, [refresh]);
218219

219220
const confirmDeleteFolderAndNotes = useCallback((folder: FolderMetadata, noteCount: number) => {
220-
const noteText = noteCount === 1 ? '1 note' : `${noteCount} notes`;
221221
Alert.alert(
222-
'Delete folder and notes?',
223-
`"${folder.name}" and ${noteText} inside it will be permanently deleted.`,
222+
t.library.deleteFolderAndNotesTitle,
223+
t.library.deleteFolderAndNotesMessage(folder.name, t.library.noteCount(noteCount)),
224224
[
225-
{ text: 'Cancel', style: 'cancel' },
225+
{ text: t.common.cancel, style: 'cancel' },
226226
{
227-
text: 'Delete All',
227+
text: t.library.deleteAllLabel,
228228
style: 'destructive',
229229
onPress: async () => {
230230
try {
231231
await deleteFolder(folder.id, 'delete-notes');
232232
await refresh();
233233
} catch (error) {
234234
if (__DEV__) console.warn('[LibraryScreen] delete folder notes failed', error);
235-
Alert.alert('Could not delete folder', 'Please try again.');
235+
Alert.alert(t.library.couldNotDeleteFolder, t.common.pleaseTryAgain);
236236
}
237237
},
238238
},
@@ -248,7 +248,7 @@ export default function LibraryScreen() {
248248
{
249249
key: 'openNotes',
250250
icon: 'heart-outline',
251-
accessibilityLabel: 'Support OpenNotes',
251+
accessibilityLabel: t.library.supportA11y,
252252
onPress: () => setAction({ kind: 'openNotes' }),
253253
},
254254
]}
@@ -259,8 +259,8 @@ export default function LibraryScreen() {
259259
</View>
260260
) : sortedFolders.length === 0 && rootNotes.length === 0 ? (
261261
<EmptyState
262-
title="No notes yet"
263-
subtitle="Tap the plus button to create your first note or folder."
262+
title={t.library.emptyTitle}
263+
subtitle={t.library.emptySubtitle}
264264
iconName="document-outline"
265265
/>
266266
) : (
@@ -269,7 +269,7 @@ export default function LibraryScreen() {
269269
showsVerticalScrollIndicator={false}
270270
>
271271
{sortedFolders.length > 0 ? (
272-
<LibrarySection title="Folders">
272+
<LibrarySection title={t.library.foldersSection}>
273273
<View style={styles.list}>
274274
{sortedFolders.map((folder) => (
275275
<FolderCard
@@ -289,7 +289,7 @@ export default function LibraryScreen() {
289289
) : null}
290290

291291
{rootNotes.length > 0 ? (
292-
<LibrarySection title={sortedFolders.length > 0 ? 'Notes' : ''}>
292+
<LibrarySection title={sortedFolders.length > 0 ? t.library.notesSection : ''}>
293293
<View style={styles.list}>
294294
{rootNotes.map((note) => (
295295
<NoteCard
@@ -341,16 +341,16 @@ export default function LibraryScreen() {
341341

342342
<ItemActionsMenu
343343
visible={action?.kind === 'newItem'}
344-
title="Create"
344+
title={t.library.createTitle}
345345
actions={[
346346
{
347347
key: 'note',
348-
label: 'New Note',
348+
label: t.library.newNote,
349349
onPress: () => setAction({ kind: 'createNoteBackground' }),
350350
},
351351
{
352352
key: 'folder',
353-
label: 'New Folder',
353+
label: t.library.newFolder,
354354
onPress: handleCreateFolder,
355355
},
356356
]}
@@ -371,17 +371,17 @@ export default function LibraryScreen() {
371371
? [
372372
{
373373
key: 'rename',
374-
label: 'Rename',
374+
label: t.common.rename,
375375
onPress: () => setAction({ kind: 'renameNote', note: action.note }),
376376
},
377377
{
378378
key: 'move',
379-
label: 'Move to Folder',
379+
label: t.library.moveToFolder,
380380
onPress: () => setAction({ kind: 'moveNote', note: action.note }),
381381
},
382382
{
383383
key: 'delete',
384-
label: 'Delete',
384+
label: t.common.delete,
385385
destructive: true,
386386
onPress: () => confirmDeleteNote(action.note),
387387
},
@@ -399,19 +399,19 @@ export default function LibraryScreen() {
399399
? [
400400
{
401401
key: 'rename',
402-
label: 'Rename',
402+
label: t.common.rename,
403403
onPress: () =>
404404
setAction({ kind: 'renameFolder', folder: action.folder }),
405405
},
406406
{
407407
key: 'orphan',
408-
label: 'Delete (keep notes)',
408+
label: t.library.deleteKeepNotesLabel,
409409
destructive: true,
410410
onPress: () => confirmDeleteFolderKeepNotes(action.folder),
411411
},
412412
{
413413
key: 'deleteNotes',
414-
label: 'Delete folder and all notes',
414+
label: t.library.deleteFolderAndNotesLabel,
415415
destructive: true,
416416
onPress: () =>
417417
confirmDeleteFolderAndNotes(
@@ -427,9 +427,9 @@ export default function LibraryScreen() {
427427

428428
<RenameDialog
429429
visible={action?.kind === 'renameNote'}
430-
title="Rename note"
430+
title={t.library.renameNoteTitle}
431431
initialValue={action?.kind === 'renameNote' ? action.note.title : ''}
432-
placeholder="Note title"
432+
placeholder={t.library.noteTitlePlaceholder}
433433
onCancel={() => setAction(null)}
434434
onConfirm={async (value) => {
435435
if (action?.kind === 'renameNote') {
@@ -442,9 +442,9 @@ export default function LibraryScreen() {
442442

443443
<RenameDialog
444444
visible={action?.kind === 'renameFolder'}
445-
title="Rename folder"
445+
title={t.library.renameFolderTitle}
446446
initialValue={action?.kind === 'renameFolder' ? action.folder.name : ''}
447-
placeholder="Folder name"
447+
placeholder={t.library.folderNamePlaceholder}
448448
onCancel={() => setAction(null)}
449449
onConfirm={async (value) => {
450450
if (action?.kind === 'renameFolder') {
@@ -457,10 +457,10 @@ export default function LibraryScreen() {
457457

458458
<RenameDialog
459459
visible={action?.kind === 'createFolder'}
460-
title="New folder"
460+
title={t.library.newFolderTitle}
461461
initialValue=""
462-
placeholder="Folder name"
463-
confirmLabel="Create"
462+
placeholder={t.library.folderNamePlaceholder}
463+
confirmLabel={t.common.create}
464464
onCancel={() => setAction(null)}
465465
onConfirm={async (value) => {
466466
if (!value.trim()) {

ios/OpenNotes.xcodeproj/project.pbxproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,7 @@
288288
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
289289
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
290290
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
291+
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoLocalization/ExpoLocalization_privacy.bundle",
291292
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
292293
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
293294
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
@@ -299,6 +300,7 @@
299300
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle",
300301
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle",
301302
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
303+
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoLocalization_privacy.bundle",
302304
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
303305
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
304306
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",

0 commit comments

Comments
 (0)