diff --git a/client/android/app/src/main/AndroidManifest.xml b/client/android/app/src/main/AndroidManifest.xml index ec0ca5e2b..e395c86a0 100644 --- a/client/android/app/src/main/AndroidManifest.xml +++ b/client/android/app/src/main/AndroidManifest.xml @@ -23,6 +23,16 @@ + + + + + + + + + + + CFBundleDocumentTypes + + + CFBundleTypeName + Booking document + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + LSItemContentTypes + + com.adobe.pdf + com.apple.ical.ics + com.apple.pkpass + com.apple.mail.email + public.email-message + + + + LSSupportsOpeningDocumentsInPlace + CFBundleURLTypes diff --git a/client/ios/App/App/SceneDelegate.swift b/client/ios/App/App/SceneDelegate.swift index 53978bc7d..f81a497d6 100644 --- a/client/ios/App/App/SceneDelegate.swift +++ b/client/ios/App/App/SceneDelegate.swift @@ -11,6 +11,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { window?.rootViewController = TrekBridgeViewController() window?.makeKeyAndVisible() + // Launched by "Open in TREK" on a booking document. + for context in connectionOptions.urlContexts where context.url.isFileURL { + TrekSharedFiles.receive(context.url) + } SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions) } @@ -25,7 +29,12 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { - SceneDelegateProxy.shared.scene(scene, openURLContexts: URLContexts) + // A booking document shared to TREK goes to the UI's import; anything + // else (the sign-in callback) stays with Capacitor. + let files = URLContexts.filter { $0.url.isFileURL } + for context in files { TrekSharedFiles.receive(context.url) } + let others = URLContexts.subtracting(files) + if !others.isEmpty { SceneDelegateProxy.shared.scene(scene, openURLContexts: others) } } func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { @@ -58,3 +67,41 @@ enum TrekPrivacyCover { cover = nil } } + +/// Booking documents shared to TREK ("Open in TREK"), held until the UI takes +/// them (TrekShell.takeSharedFile) and announced if it is already running. +enum TrekSharedFiles { + /// Above this the file is not a booking confirmation, and base64 over the + /// bridge would only stall the app. + static let maxBytes = 15 * 1024 * 1024 + private(set) static var pending: [String: Any]? + static var onReceive: (([String: Any]) -> Void)? + + static func receive(_ url: URL) { + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + guard let data = try? Data(contentsOf: url), data.count <= maxBytes else { return } + let file: [String: Any] = [ + "name": url.lastPathComponent, + "mimeType": mimeType(for: url.pathExtension.lowercased()), + "data": data.base64EncodedString(), + ] + pending = file + onReceive?(file) + } + + static func take() -> [String: Any]? { + defer { pending = nil } + return pending + } + + private static func mimeType(for ext: String) -> String { + switch ext { + case "pdf": return "application/pdf" + case "ics": return "text/calendar" + case "pkpass": return "application/vnd.apple.pkpass" + case "eml": return "message/rfc822" + default: return "application/octet-stream" + } + } +} diff --git a/client/ios/App/App/TrekShellPlugin.swift b/client/ios/App/App/TrekShellPlugin.swift index d5f79bad2..e1c6f2a5f 100644 --- a/client/ios/App/App/TrekShellPlugin.swift +++ b/client/ios/App/App/TrekShellPlugin.swift @@ -26,6 +26,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr CAPPluginMethod(name: "setPrivacyCover", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "startLocation", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "stopLocation", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "takeSharedFile", returnType: CAPPluginReturnPromise), ] /// What this binary offers the server's UI, raised with every native feature @@ -33,7 +34,24 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr /// and a server can require a minimum (minNativeShellApi in /api/health/features). static let shellApi = 2 /// The native features by name, so the UI can turn each on by itself. - static let features: [String] = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation"] + static let features: [String] = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles"] + + // MARK: - Shared booking documents + + override public func load() { + TrekSharedFiles.onReceive = { [weak self] file in + DispatchQueue.main.async { self?.notifyListeners("sharedFile", data: file) } + } + } + + /// The document shared to TREK before the UI was there to hear of it. + @objc func takeSharedFile(_ call: CAPPluginCall) { + if let file = TrekSharedFiles.take() { + call.resolve(["file": file]) + } else { + call.resolve(["file": NSNull()]) + } + } // MARK: - Location diff --git a/client/src/App.tsx b/client/src/App.tsx index bc335d8b5..303af78fd 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -34,6 +34,7 @@ import OfflineBanner from './components/Layout/OfflineBanner' import NewVersionNotice from './components/Layout/NewVersionNotice' import NativeUpdateNotice from './components/Layout/NativeUpdateNotice' import AppLockGate from './components/Layout/AppLockGate' +import NativeImportSheet from './components/Layout/NativeImportSheet' import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js' import HelpPanel from './components/Help/HelpPanel' // Notice action registrations (side-effect imports): @@ -446,6 +447,7 @@ export default function App() { + {/* One boundary for all route chunks, above so it stays mounted across navigations. react-router runs location updates inside a transition, so a mounted boundary keeps the current page on screen instead of flashing diff --git a/client/src/components/Layout/NativeImportSheet.test.tsx b/client/src/components/Layout/NativeImportSheet.test.tsx new file mode 100644 index 000000000..74ed44ec7 --- /dev/null +++ b/client/src/components/Layout/NativeImportSheet.test.tsx @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen } from '../../../tests/helpers/render'; +import { tripsApi } from '../../api/client'; +import { useSharedImportStore } from '../../native/sharedFiles'; +import NativeImportSheet from './NativeImportSheet'; + +const navigate = vi.hoisted(() => vi.fn()); +vi.mock('react-router', async (importOriginal) => ({ + ...(await importOriginal()), + useNavigate: () => navigate, +})); + +beforeEach(() => { + navigate.mockReset(); + useSharedImportStore.setState({ file: null, tripId: null }); + vi.spyOn(tripsApi, 'list').mockResolvedValue({ + trips: [ + { id: 1, title: 'Past trip', start_date: '2020-01-01', end_date: '2020-01-05' }, + { id: 2, title: 'Japan', start_date: '2099-03-01', end_date: '2099-03-10' }, + ], + }); +}); + +describe('NativeImportSheet', () => { + it('asks which trip a shared document goes to, upcoming trips first, and opens it there', async () => { + render(); + act(() => useSharedImportStore.getState().setFile(new File(['x'], 'booking.pdf', { type: 'application/pdf' }))); + + const rows = await screen.findAllByRole('button', { name: /Japan|Past trip/ }); + expect(rows.map((r) => r.textContent)).toEqual(['Japan', 'Past trip']); + expect(screen.getByText('booking.pdf')).toBeInTheDocument(); + + fireEvent.click(rows[0]); + expect(useSharedImportStore.getState().tripId).toBe(2); + expect(navigate).toHaveBeenCalledWith('/trips/2'); + }); + + it('stays closed with nothing shared', () => { + render(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(tripsApi.list).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/Layout/NativeImportSheet.tsx b/client/src/components/Layout/NativeImportSheet.tsx new file mode 100644 index 000000000..c91a1e936 --- /dev/null +++ b/client/src/components/Layout/NativeImportSheet.tsx @@ -0,0 +1,80 @@ +/** + * NativeImportSheet: a booking document was shared to TREK (native/sharedFiles.ts), + * so this asks which trip it belongs to and opens that trip's booking import with + * the file already chosen. Trips still ahead come first, the ones that ended after. + */ +import { ChevronRight, FileText } from 'lucide-react'; +import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router'; +import { tripsApi } from '../../api/client'; +import { useTranslation } from '../../i18n'; +import MSheet from '../../mobile/components/MSheet'; +import { useSharedImportStore } from '../../native/sharedFiles'; + +interface TripRow { + id: number; + title: string; + start_date?: string | null; + end_date?: string | null; +} + +export default function NativeImportSheet(): React.ReactElement | null { + const { t } = useTranslation(); + const navigate = useNavigate(); + const file = useSharedImportStore((s) => s.file); + const tripId = useSharedImportStore((s) => s.tripId); + const chooseTrip = useSharedImportStore((s) => s.chooseTrip); + const setFile = useSharedImportStore((s) => s.setFile); + const [trips, setTrips] = useState([]); + + const open = !!file && tripId == null; + + useEffect(() => { + if (!open) return; + tripsApi.list().then( + (data: { trips?: TripRow[] }) => { + const today = new Date().toISOString().slice(0, 10); + const list = [...(data.trips ?? [])]; + const ahead = (trip: TripRow) => (trip.end_date ?? '9999') >= today; + list.sort( + (a, b) => Number(ahead(b)) - Number(ahead(a)) || (a.start_date ?? '').localeCompare(b.start_date ?? '') + ); + setTrips(list); + }, + () => setTrips([]) + ); + }, [open]); + + const pick = (id: number) => { + chooseTrip(id); + navigate(`/trips/${id}`); + }; + + return ( + setFile(null)} variant="bottom" ariaLabel={t('native.share.title')}> +
+
{t('native.share.title')}
+ {file && ( +
+
+ )} +
+ {trips.map((trip) => ( + + ))} + {trips.length === 0 &&

{t('native.share.noTrips')}

} +
+
+
+ ); +} diff --git a/client/src/components/Planner/BookingImportModal.tsx b/client/src/components/Planner/BookingImportModal.tsx index aa84b9eef..8955e12ce 100644 --- a/client/src/components/Planner/BookingImportModal.tsx +++ b/client/src/components/Planner/BookingImportModal.tsx @@ -1,4 +1,5 @@ import { createPortal } from 'react-dom' +import { useSharedImportStore } from '../../native/sharedFiles' import { useState, useRef, useEffect } from 'react' import { Upload, X } from 'lucide-react' import { useTranslation } from '../../i18n' @@ -35,6 +36,12 @@ export default function BookingImportModal({ isOpen, onClose, tripId, kind }: Bo const mouseDownTarget = useRef(null) const [files, setFiles] = useState([]) + // A booking document shared to TREK lands here already chosen. + useEffect(() => { + if (!isOpen) return + const shared = useSharedImportStore.getState().file + if (shared) setFiles([useSharedImportStore.getState().take() ?? shared]) + }, [isOpen]) const [isDragOver, setIsDragOver] = useState(false) const [loading, setLoading] = useState(false) const [error, setError] = useState('') diff --git a/client/src/mobile/screens/trip/MTripShell.tsx b/client/src/mobile/screens/trip/MTripShell.tsx index cc69e8551..95526dd92 100644 --- a/client/src/mobile/screens/trip/MTripShell.tsx +++ b/client/src/mobile/screens/trip/MTripShell.tsx @@ -9,6 +9,7 @@ import { pickDockTabs } from './dockTabs' import MBadge from '../../components/MBadge' import MIconBtn from '../../components/MIconBtn' import { useActivePill } from '../../components/useActivePill' +import { useSharedImportStore } from '../../../native/sharedFiles' import MPlanTimeline from './plan/MPlanTimeline' import MMapArea from './map/MMapArea' import MPlacesBrowser from './places/MPlacesBrowser' @@ -291,6 +292,14 @@ export default function MTripShell({ }) }, [planner.selectedDayId, days, dayRail]) // The active day's pill glides to the next chip rather than jumping there. + // A booking document shared to TREK, and this trip picked for it: open the import. + const sharedTripId = useSharedImportStore(s => s.tripId) + useEffect(() => { + if (sharedTripId == null || sharedTripId !== Number(tripId)) return + planner.setBookingImportKind('bookings') + planner.setShowBookingImport(true) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sharedTripId, tripId]) const dayPill = useActivePill(dayRail, '[aria-current="true"]', [planner.selectedDayId, days, dayRail, language]) const trTab = planner.activeTab diff --git a/client/src/native/nativeApp.test.ts b/client/src/native/nativeApp.test.ts index ba0cae970..f85b54f9d 100644 --- a/client/src/native/nativeApp.test.ts +++ b/client/src/native/nativeApp.test.ts @@ -23,6 +23,7 @@ vi.mock('./capabilities', () => ({ loadNativeCapabilities: vi.fn(async () => ({})), })); vi.mock('./geolocation', () => ({ installNativeGeolocation: vi.fn() })); +vi.mock('./sharedFiles', () => ({ startSharedFiles: vi.fn(async () => {}) })); vi.mock('../mobile/overlayStack', () => ({ closeTopOverlay: vi.fn(() => false) })); function backButtonHandler(): (event: { canGoBack: boolean }) => void { diff --git a/client/src/native/nativeApp.ts b/client/src/native/nativeApp.ts index efe7feff8..091d439bf 100644 --- a/client/src/native/nativeApp.ts +++ b/client/src/native/nativeApp.ts @@ -9,6 +9,7 @@ import { installNativeGeolocation } from './geolocation'; import { keepOfflineShellFresh } from './offlineShell'; import { isNativeApp, isNativeIos } from './platform'; import { reportNativeFailure } from './reportFailure'; +import { startSharedFiles } from './sharedFiles'; import { isUnimplemented } from './trekShell'; /** Long enough that the warm-up never competes with the first screen's requests. */ @@ -27,6 +28,8 @@ export function startNativeApp(): void { void checkAppUpdate(fetchServerFeatures); // The app's own location under navigator.geolocation, where the binary has it. void loadNativeCapabilities().then(() => installNativeGeolocation()); + // Booking documents shared to TREK, waiting for a trip to import into. + void startSharedFiles(); // A tap on the status bar scrolls the list under it back to the top, as in // every iOS app (the status-bar plugin fires statusTap on iOS). diff --git a/client/src/native/sharedFiles.test.ts b/client/src/native/sharedFiles.test.ts new file mode 100644 index 000000000..e7b41566d --- /dev/null +++ b/client/src/native/sharedFiles.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { hasNativeFeature } from './capabilities'; +import { startSharedFiles, toFile, useSharedImportStore } from './sharedFiles'; +import { TrekShell } from './trekShell'; + +vi.mock('./capabilities', () => ({ hasNativeFeature: vi.fn(), loadNativeCapabilities: vi.fn(async () => ({})) })); +vi.mock('./trekShell', () => ({ + TrekShell: { addListener: vi.fn(async () => ({ remove: async () => {} })), takeSharedFile: vi.fn() }, + isUnimplemented: () => false, +})); + +const PDF = { name: 'booking.pdf', mimeType: 'application/pdf', data: btoa('%PDF-1.4 hello') }; + +beforeEach(() => { + useSharedImportStore.setState({ file: null, tripId: null }); + vi.mocked(hasNativeFeature).mockReturnValue(true); + vi.mocked(TrekShell.takeSharedFile).mockReset().mockResolvedValue({ file: null }); +}); + +describe('shared booking documents', () => { + it('turns the base64 the native side sends into a File', async () => { + const file = toFile(PDF); + expect(file.name).toBe('booking.pdf'); + expect(file.type).toBe('application/pdf'); + expect(await file.text()).toBe('%PDF-1.4 hello'); + }); + + it('picks up a document shared before the UI ran, and the next ones as they come', async () => { + vi.mocked(TrekShell.takeSharedFile).mockResolvedValue({ file: PDF }); + await startSharedFiles(); + expect(useSharedImportStore.getState().file?.name).toBe('booking.pdf'); + + const listener = vi.mocked(TrekShell.addListener).mock.calls[0][1] as (p: typeof PDF) => void; + listener({ ...PDF, name: 'train.ics', mimeType: 'text/calendar' }); + expect(useSharedImportStore.getState().file?.name).toBe('train.ics'); + }); + + it('hands the file over once a trip is picked, and empties the queue', () => { + useSharedImportStore.getState().setFile(toFile(PDF)); + useSharedImportStore.getState().chooseTrip(7); + expect(useSharedImportStore.getState().tripId).toBe(7); + expect(useSharedImportStore.getState().take()?.name).toBe('booking.pdf'); + expect(useSharedImportStore.getState()).toMatchObject({ file: null, tripId: null }); + }); + + it('does nothing in a binary without the feature', async () => { + vi.mocked(hasNativeFeature).mockReturnValue(false); + vi.mocked(TrekShell.addListener).mockClear(); + await startSharedFiles(); + expect(TrekShell.addListener).not.toHaveBeenCalled(); + expect(TrekShell.takeSharedFile).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/native/sharedFiles.ts b/client/src/native/sharedFiles.ts new file mode 100644 index 000000000..830b9c00f --- /dev/null +++ b/client/src/native/sharedFiles.ts @@ -0,0 +1,55 @@ +import { create } from 'zustand'; +import { hasNativeFeature, loadNativeCapabilities } from './capabilities'; +import { reportNativeFailure } from './reportFailure'; +import { TrekShell, isUnimplemented, type SharedFilePayload } from './trekShell'; + +/** + * Booking documents shared to TREK ("Open in TREK" on a PDF, a calendar entry, + * a Wallet pass or a mail). The native side hands them over as base64 + * (TrekShell.takeSharedFile and the sharedFile event); here they become a File + * waiting for a trip: NativeImportSheet asks which one, and the trip's booking + * import opens with the file already chosen (BookingImportModal). + */ +interface SharedImportState { + file: File | null; + /** The trip the user picked for it; the planner of that trip takes it from here. */ + tripId: number | null; + setFile: (file: File | null) => void; + chooseTrip: (tripId: number) => void; + /** The booking import took the file: the queue is empty again. */ + take: () => File | null; +} + +export const useSharedImportStore = create((set, get) => ({ + file: null, + tripId: null, + setFile: (file) => set({ file, tripId: null }), + chooseTrip: (tripId) => set({ tripId }), + take: () => { + const { file } = get(); + set({ file: null, tripId: null }); + return file; + }, +})); + +export function toFile(payload: SharedFilePayload): File { + const binary = atob(payload.data); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new File([bytes], payload.name || 'booking', { type: payload.mimeType || 'application/octet-stream' }); +} + +/** Picks up a document shared before the UI ran, and listens for the next ones. */ +export async function startSharedFiles(): Promise { + await loadNativeCapabilities(); + if (!hasNativeFeature('sharedFiles')) return; + const accept = (payload: SharedFilePayload | null) => { + if (payload?.data) useSharedImportStore.getState().setFile(toFile(payload)); + }; + void TrekShell.addListener('sharedFile', accept); + try { + accept((await TrekShell.takeSharedFile()).file); + } catch (err) { + if (!isUnimplemented(err)) reportNativeFailure(err); + } +} diff --git a/client/src/native/trekShell.ts b/client/src/native/trekShell.ts index afe9c0ad9..097323b60 100644 --- a/client/src/native/trekShell.ts +++ b/client/src/native/trekShell.ts @@ -49,6 +49,20 @@ export interface TrekShellPlugin { event: 'location', listener: (payload: NativeLocationPayload) => void ): Promise<{ remove: () => Promise }>; + addListener( + event: 'sharedFile', + listener: (payload: SharedFilePayload) => void + ): Promise<{ remove: () => Promise }>; + /** Feature sharedFiles: the booking document shared to TREK before the UI ran. */ + takeSharedFile(): Promise<{ file: SharedFilePayload | null }>; +} + +/** A document shared to TREK, as the native side reads it. */ +export interface SharedFilePayload { + name: string; + mimeType: string; + /** base64 */ + data: string; } /** A position, or an error shaped like GeolocationPositionError (1 denied, 2 unavailable). */ diff --git a/shared/src/i18n/ar/native.ts b/shared/src/i18n/ar/native.ts index f445c5288..0ded8f83f 100644 --- a/shared/src/i18n/ar/native.ts +++ b/shared/src/i18n/ar/native.ts @@ -80,6 +80,8 @@ const native: TranslationStrings = { 'native.lock.settingTitle': 'قفل التطبيق', 'native.lock.setting': 'القفل باستخدام Face ID أو رمز المرور', 'native.lock.settingHint': 'يطلب ذلك عند فتح TREK وبعد نصف دقيقة في الخلفية، ويخفي التطبيق في مبدّل التطبيقات.', + 'native.share.title': 'الاستيراد إلى أي رحلة؟', + 'native.share.noTrips': 'لا توجد رحلة بعد. أنشئ رحلة أولًا ثم شارك المستند مجددًا.', }; export default native; diff --git a/shared/src/i18n/az/native.ts b/shared/src/i18n/az/native.ts index aeba3db30..1c5b8b7f5 100644 --- a/shared/src/i18n/az/native.ts +++ b/shared/src/i18n/az/native.ts @@ -82,6 +82,8 @@ const native: TranslationStrings = { 'native.lock.setting': 'Face ID və ya kodla kilidlə', 'native.lock.settingHint': 'TREK açılanda və arxa fonda yarım dəqiqədən sonra soruşur, tətbiqi tətbiq dəyişdiricisində gizlədir.', + 'native.share.title': 'Hansı səyahətə idxal edilsin?', + 'native.share.noTrips': 'Hələ səyahət yoxdur. Əvvəlcə birini yaradın, sonra sənədi yenidən paylaşın.', }; export default native; diff --git a/shared/src/i18n/br/native.ts b/shared/src/i18n/br/native.ts index 6005fb0b4..7a540640d 100644 --- a/shared/src/i18n/br/native.ts +++ b/shared/src/i18n/br/native.ts @@ -82,6 +82,8 @@ const native: TranslationStrings = { 'native.lock.setting': 'Bloquear com Face ID ou código', 'native.lock.settingHint': 'Pede ao abrir o TREK e após meio minuto em segundo plano, e oculta o app no seletor de apps.', + 'native.share.title': 'Importar para qual viagem?', + 'native.share.noTrips': 'Ainda não há viagens. Crie uma primeiro e compartilhe o documento de novo.', }; export default native; diff --git a/shared/src/i18n/ca/native.ts b/shared/src/i18n/ca/native.ts index 6f7420077..c007e6386 100644 --- a/shared/src/i18n/ca/native.ts +++ b/shared/src/i18n/ca/native.ts @@ -82,6 +82,8 @@ const native: TranslationStrings = { 'native.lock.setting': 'Bloqueja amb Face ID o codi', 'native.lock.settingHint': 'Ho demana en obrir TREK i després de mig minut en segon pla, i amaga l’app al selector d’apps.', + 'native.share.title': 'A quin viatge vols importar-ho?', + 'native.share.noTrips': 'Encara no hi ha cap viatge. Crea’n un i torna a compartir el document.', }; export default native; diff --git a/shared/src/i18n/cs/native.ts b/shared/src/i18n/cs/native.ts index b063a899f..73fba6fa8 100644 --- a/shared/src/i18n/cs/native.ts +++ b/shared/src/i18n/cs/native.ts @@ -82,6 +82,8 @@ const native: TranslationStrings = { 'native.lock.setting': 'Zamknout pomocí Face ID nebo kódu', 'native.lock.settingHint': 'Zeptá se při otevření TREK a po půl minutě na pozadí a skryje aplikaci v přepínači aplikací.', + 'native.share.title': 'Importovat do které cesty?', + 'native.share.noTrips': 'Zatím žádná cesta. Nejdřív nějakou vytvořte a pak dokument sdílejte znovu.', }; export default native; diff --git a/shared/src/i18n/de/native.ts b/shared/src/i18n/de/native.ts index 4aac52592..39444941e 100644 --- a/shared/src/i18n/de/native.ts +++ b/shared/src/i18n/de/native.ts @@ -83,6 +83,8 @@ const native: TranslationStrings = { 'native.lock.setting': 'Mit Face ID oder Code sperren', 'native.lock.settingHint': 'Fragt beim Öffnen und nach einer halben Minute im Hintergrund nach und verbirgt TREK im App-Umschalter.', + 'native.share.title': 'In welchen Trip importieren?', + 'native.share.noTrips': 'Noch kein Trip. Lege zuerst einen an und teile das Dokument dann erneut.', }; export default native; diff --git a/shared/src/i18n/en/native.ts b/shared/src/i18n/en/native.ts index 31f603099..35b668677 100644 --- a/shared/src/i18n/en/native.ts +++ b/shared/src/i18n/en/native.ts @@ -82,6 +82,8 @@ const native: TranslationStrings = { 'native.lock.setting': 'Lock with Face ID or passcode', 'native.lock.settingHint': 'Asks when TREK opens and after half a minute in the background, and hides the app in the app switcher.', + 'native.share.title': 'Import into which trip?', + 'native.share.noTrips': 'No trip yet. Create one first, then share the document again.', }; export default native; diff --git a/shared/src/i18n/es/native.ts b/shared/src/i18n/es/native.ts index 4279a01f8..d54810e8a 100644 --- a/shared/src/i18n/es/native.ts +++ b/shared/src/i18n/es/native.ts @@ -83,6 +83,8 @@ const native: TranslationStrings = { 'native.lock.setting': 'Bloquear con Face ID o código', 'native.lock.settingHint': 'Lo pide al abrir TREK y tras medio minuto en segundo plano, y oculta la app en el selector de apps.', + 'native.share.title': '¿En qué viaje importar?', + 'native.share.noTrips': 'Aún no hay viajes. Crea uno primero y vuelve a compartir el documento.', }; export default native; diff --git a/shared/src/i18n/et/native.ts b/shared/src/i18n/et/native.ts index 39facd3c5..98ecfb60b 100644 --- a/shared/src/i18n/et/native.ts +++ b/shared/src/i18n/et/native.ts @@ -82,6 +82,8 @@ const native: TranslationStrings = { 'native.lock.setting': 'Lukusta Face ID või pääsukoodiga', 'native.lock.settingHint': 'Küsib TREK-i avamisel ja pärast poolt minutit taustal ning peidab rakenduse rakenduste vahetajas.', + 'native.share.title': 'Millisesse reisi importida?', + 'native.share.noTrips': 'Reise veel pole. Loo esmalt üks ja jaga dokumenti uuesti.', }; export default native; diff --git a/shared/src/i18n/fr/native.ts b/shared/src/i18n/fr/native.ts index 5329ee437..4a4fd48d7 100644 --- a/shared/src/i18n/fr/native.ts +++ b/shared/src/i18n/fr/native.ts @@ -83,6 +83,8 @@ const native: TranslationStrings = { 'native.lock.setting': 'Verrouiller avec Face ID ou code', 'native.lock.settingHint': 'Le demande à l’ouverture de TREK et après une demi-minute en arrière-plan, et masque l’app dans le sélecteur d’apps.', + 'native.share.title': 'Importer dans quel voyage ?', + 'native.share.noTrips': 'Pas encore de voyage. Créez-en un, puis partagez à nouveau le document.', }; export default native; diff --git a/shared/src/i18n/gr/native.ts b/shared/src/i18n/gr/native.ts index 312b9dbbb..f666f08ee 100644 --- a/shared/src/i18n/gr/native.ts +++ b/shared/src/i18n/gr/native.ts @@ -85,6 +85,8 @@ const native: TranslationStrings = { 'native.lock.setting': 'Κλείδωμα με Face ID ή κωδικό', 'native.lock.settingHint': 'Ζητείται κατά το άνοιγμα του TREK και μετά από μισό λεπτό στο παρασκήνιο, και η εφαρμογή κρύβεται στην εναλλαγή εφαρμογών.', + 'native.share.title': 'Εισαγωγή σε ποιο ταξίδι;', + 'native.share.noTrips': 'Δεν υπάρχει ακόμα ταξίδι. Δημιουργήστε ένα πρώτα και κοινοποιήστε ξανά το έγγραφο.', }; export default native; diff --git a/shared/src/i18n/he/native.ts b/shared/src/i18n/he/native.ts index e201b220d..da74576c8 100644 --- a/shared/src/i18n/he/native.ts +++ b/shared/src/i18n/he/native.ts @@ -80,6 +80,8 @@ const native: TranslationStrings = { 'native.lock.settingTitle': 'נעילת אפליקציה', 'native.lock.setting': 'נעילה עם Face ID או קוד', 'native.lock.settingHint': 'מבקש בעת פתיחת TREK ואחרי חצי דקה ברקע, ומסתיר את האפליקציה במחליף האפליקציות.', + 'native.share.title': 'לייבא לאיזה טיול?', + 'native.share.noTrips': 'אין עדיין טיול. צרו טיול ואז שתפו את המסמך שוב.', }; export default native; diff --git a/shared/src/i18n/hu/native.ts b/shared/src/i18n/hu/native.ts index 29dec6f7e..194b29540 100644 --- a/shared/src/i18n/hu/native.ts +++ b/shared/src/i18n/hu/native.ts @@ -84,6 +84,8 @@ const native: TranslationStrings = { 'native.lock.setting': 'Zárolás Face ID-val vagy kóddal', 'native.lock.settingHint': 'Rákérdez a TREK megnyitásakor és fél perc háttérben töltött idő után, és elrejti az alkalmazást az alkalmazásváltóban.', + 'native.share.title': 'Melyik utazásba importáljuk?', + 'native.share.noTrips': 'Még nincs utazás. Előbb hozz létre egyet, aztán oszd meg újra a dokumentumot.', }; export default native; diff --git a/shared/src/i18n/id/native.ts b/shared/src/i18n/id/native.ts index 8df8c449d..47e658e33 100644 --- a/shared/src/i18n/id/native.ts +++ b/shared/src/i18n/id/native.ts @@ -83,6 +83,8 @@ const native: TranslationStrings = { 'native.lock.setting': 'Kunci dengan Face ID atau kode sandi', 'native.lock.settingHint': 'Meminta saat TREK dibuka dan setelah setengah menit di latar belakang, serta menyembunyikan aplikasi di pengalih aplikasi.', + 'native.share.title': 'Impor ke perjalanan mana?', + 'native.share.noTrips': 'Belum ada perjalanan. Buat satu dulu, lalu bagikan dokumennya lagi.', }; export default native; diff --git a/shared/src/i18n/it/native.ts b/shared/src/i18n/it/native.ts index f245287a4..7891b8ec8 100644 --- a/shared/src/i18n/it/native.ts +++ b/shared/src/i18n/it/native.ts @@ -83,6 +83,8 @@ const native: TranslationStrings = { 'native.lock.setting': 'Blocca con Face ID o codice', 'native.lock.settingHint': 'Lo chiede all’apertura di TREK e dopo mezzo minuto in background, e nasconde l’app nel selettore delle app.', + 'native.share.title': 'Importare in quale viaggio?', + 'native.share.noTrips': 'Ancora nessun viaggio. Creane uno, poi condividi di nuovo il documento.', }; export default native; diff --git a/shared/src/i18n/ja/native.ts b/shared/src/i18n/ja/native.ts index 47c78932e..1c87a190d 100644 --- a/shared/src/i18n/ja/native.ts +++ b/shared/src/i18n/ja/native.ts @@ -83,6 +83,8 @@ const native: TranslationStrings = { 'native.lock.setting': 'Face IDまたはパスコードでロック', 'native.lock.settingHint': 'TREKを開いたときと、バックグラウンドで30秒経過したあとに確認し、アプリスイッチャーでは内容を隠します。', + 'native.share.title': 'どの旅行に取り込みますか?', + 'native.share.noTrips': 'まだ旅行がありません。先に作成してから、もう一度ドキュメントを共有してください。', }; export default native; diff --git a/shared/src/i18n/ko/native.ts b/shared/src/i18n/ko/native.ts index 806778b9c..522ba27aa 100644 --- a/shared/src/i18n/ko/native.ts +++ b/shared/src/i18n/ko/native.ts @@ -81,6 +81,8 @@ const native: TranslationStrings = { 'native.lock.setting': 'Face ID 또는 암호로 잠금', 'native.lock.settingHint': 'TREK을 열 때와 백그라운드에서 30초가 지난 후에 확인하며, 앱 전환기에서는 내용을 숨깁니다.', + 'native.share.title': '어느 여행으로 가져올까요?', + 'native.share.noTrips': '아직 여행이 없습니다. 먼저 여행을 만든 뒤 문서를 다시 공유하세요.', }; export default native; diff --git a/shared/src/i18n/nl/native.ts b/shared/src/i18n/nl/native.ts index 77194910e..430a3bbef 100644 --- a/shared/src/i18n/nl/native.ts +++ b/shared/src/i18n/nl/native.ts @@ -82,6 +82,8 @@ const native: TranslationStrings = { 'native.lock.setting': 'Vergrendelen met Face ID of toegangscode', 'native.lock.settingHint': 'Vraagt erom bij het openen van TREK en na een halve minuut op de achtergrond, en verbergt de app in de appkiezer.', + 'native.share.title': 'In welke reis importeren?', + 'native.share.noTrips': 'Nog geen reis. Maak er eerst een aan en deel het document dan opnieuw.', }; export default native; diff --git a/shared/src/i18n/pl/native.ts b/shared/src/i18n/pl/native.ts index 31c9153de..8b17f18f1 100644 --- a/shared/src/i18n/pl/native.ts +++ b/shared/src/i18n/pl/native.ts @@ -82,6 +82,8 @@ const native: TranslationStrings = { 'native.lock.setting': 'Blokuj za pomocą Face ID lub kodu', 'native.lock.settingHint': 'Pyta przy otwieraniu TREK i po pół minuty w tle oraz ukrywa aplikację w przełączniku aplikacji.', + 'native.share.title': 'Do której podróży zaimportować?', + 'native.share.noTrips': 'Nie ma jeszcze podróży. Najpierw utwórz podróż, a potem udostępnij dokument ponownie.', }; export default native; diff --git a/shared/src/i18n/ru/native.ts b/shared/src/i18n/ru/native.ts index f08233c3e..976e0a0dd 100644 --- a/shared/src/i18n/ru/native.ts +++ b/shared/src/i18n/ru/native.ts @@ -83,6 +83,8 @@ const native: TranslationStrings = { 'native.lock.setting': 'Блокировать с Face ID или кодом', 'native.lock.settingHint': 'Запрашивается при открытии TREK и через полминуты в фоне, а в переключателе приложений содержимое скрыто.', + 'native.share.title': 'В какую поездку импортировать?', + 'native.share.noTrips': 'Поездок пока нет. Сначала создайте поездку, затем снова поделитесь документом.', }; export default native; diff --git a/shared/src/i18n/sk/native.ts b/shared/src/i18n/sk/native.ts index 85a19799d..a140f0352 100644 --- a/shared/src/i18n/sk/native.ts +++ b/shared/src/i18n/sk/native.ts @@ -82,6 +82,8 @@ const native: TranslationStrings = { 'native.lock.setting': 'Zamknúť pomocou Face ID alebo kódu', 'native.lock.settingHint': 'Opýta sa pri otvorení TREK a po pol minúte na pozadí a skryje aplikáciu v prepínači aplikácií.', + 'native.share.title': 'Importovať do ktorej cesty?', + 'native.share.noTrips': 'Zatiaľ žiadna cesta. Najprv nejakú vytvorte a potom dokument zdieľajte znova.', }; export default native; diff --git a/shared/src/i18n/sv/native.ts b/shared/src/i18n/sv/native.ts index 51c460c96..fe3787c79 100644 --- a/shared/src/i18n/sv/native.ts +++ b/shared/src/i18n/sv/native.ts @@ -83,6 +83,8 @@ const native: TranslationStrings = { 'native.lock.setting': 'Lås med Face ID eller lösenkod', 'native.lock.settingHint': 'Frågar när TREK öppnas och efter en halv minut i bakgrunden, och döljer appen i appväxlaren.', + 'native.share.title': 'Importera till vilken resa?', + 'native.share.noTrips': 'Ingen resa ännu. Skapa en först och dela sedan dokumentet igen.', }; export default native; diff --git a/shared/src/i18n/th/native.ts b/shared/src/i18n/th/native.ts index 176131319..5d11811cb 100644 --- a/shared/src/i18n/th/native.ts +++ b/shared/src/i18n/th/native.ts @@ -80,6 +80,8 @@ const native: TranslationStrings = { 'native.lock.settingTitle': 'ล็อกแอป', 'native.lock.setting': 'ล็อกด้วย Face ID หรือรหัส', 'native.lock.settingHint': 'ถามเมื่อเปิด TREK และหลังจากอยู่เบื้องหลังครึ่งนาที และซ่อนแอปในตัวสลับแอป', + 'native.share.title': 'นำเข้าไปยังทริปไหน', + 'native.share.noTrips': 'ยังไม่มีทริป สร้างทริปก่อนแล้วแชร์เอกสารอีกครั้ง', }; export default native; diff --git a/shared/src/i18n/tr/native.ts b/shared/src/i18n/tr/native.ts index 06e87ba05..deebeaf4e 100644 --- a/shared/src/i18n/tr/native.ts +++ b/shared/src/i18n/tr/native.ts @@ -83,6 +83,8 @@ const native: TranslationStrings = { 'native.lock.setting': 'Face ID veya parola ile kilitle', 'native.lock.settingHint': 'TREK açılırken ve arka planda yarım dakika sonra sorar, uygulamayı uygulama değiştiricide gizler.', + 'native.share.title': 'Hangi geziye aktarılsın?', + 'native.share.noTrips': 'Henüz gezi yok. Önce bir gezi oluşturun, sonra belgeyi yeniden paylaşın.', }; export default native; diff --git a/shared/src/i18n/uk/native.ts b/shared/src/i18n/uk/native.ts index 64dc63398..d2bb8f289 100644 --- a/shared/src/i18n/uk/native.ts +++ b/shared/src/i18n/uk/native.ts @@ -82,6 +82,8 @@ const native: TranslationStrings = { 'native.lock.setting': 'Блокувати за допомогою Face ID або коду', 'native.lock.settingHint': 'Запитує під час відкриття TREK і після пів хвилини у фоні, а в перемикачі застосунків вміст приховано.', + 'native.share.title': 'До якої подорожі імпортувати?', + 'native.share.noTrips': 'Подорожей ще немає. Спершу створіть подорож, а потім знову поділіться документом.', }; export default native; diff --git a/shared/src/i18n/vi/native.ts b/shared/src/i18n/vi/native.ts index 5902d6590..c8134c429 100644 --- a/shared/src/i18n/vi/native.ts +++ b/shared/src/i18n/vi/native.ts @@ -83,6 +83,8 @@ const native: TranslationStrings = { 'native.lock.setting': 'Khóa bằng Face ID hoặc mật mã', 'native.lock.settingHint': 'Hỏi khi mở TREK và sau nửa phút chạy nền, đồng thời ẩn ứng dụng trong trình chuyển ứng dụng.', + 'native.share.title': 'Nhập vào chuyến đi nào?', + 'native.share.noTrips': 'Chưa có chuyến đi nào. Hãy tạo một chuyến trước rồi chia sẻ lại tài liệu.', }; export default native; diff --git a/shared/src/i18n/zh-TW/native.ts b/shared/src/i18n/zh-TW/native.ts index 71ba73f2c..e781ff8f3 100644 --- a/shared/src/i18n/zh-TW/native.ts +++ b/shared/src/i18n/zh-TW/native.ts @@ -80,6 +80,8 @@ const native: TranslationStrings = { 'native.lock.settingTitle': 'App 鎖定', 'native.lock.setting': '使用 Face ID 或密碼鎖定', 'native.lock.settingHint': '開啟 TREK 時以及在背景停留半分鐘後會要求驗證,並在 App 切換器中隱藏內容。', + 'native.share.title': '要匯入到哪個行程?', + 'native.share.noTrips': '還沒有行程。請先建立一個,然後再次分享該文件。', }; export default native; diff --git a/shared/src/i18n/zh/native.ts b/shared/src/i18n/zh/native.ts index b7f9e74ec..3e6a8cc2d 100644 --- a/shared/src/i18n/zh/native.ts +++ b/shared/src/i18n/zh/native.ts @@ -80,6 +80,8 @@ const native: TranslationStrings = { 'native.lock.settingTitle': '应用锁', 'native.lock.setting': '使用面容 ID 或密码锁定', 'native.lock.settingHint': '打开 TREK 时以及在后台停留半分钟后会要求验证,并在应用切换器中隐藏内容。', + 'native.share.title': '导入到哪个行程?', + 'native.share.noTrips': '还没有行程。请先创建一个,然后再次分享该文档。', }; export default native;