Xlbully's picture
v2 offline build: guided flow, export fixes, speed instrumentation, and usage notes
a3d9e9b
import React, { useEffect, useMemo, useState } from 'react';
import { StatusBar } from 'expo-status-bar';
import { useKeepAwake } from 'expo-keep-awake';
import { Asset } from 'expo-asset';
import { ActivityIndicator, Pressable, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import { NativeModules } from 'react-native';
import * as DocumentPicker from 'expo-document-picker';
import * as FileSystem from 'expo-file-system/legacy';
import { createClipTokenizer } from './clipTokenizer';
const BUILD_ID = '2026-02-12-v2-speed-exp-002';
const QUICK_STEPS = 4;
const QUALITY_STEPS = 20;
const GUIDE_STEPS = [
{ key: 'tapLaunch', label: 'Tap to confirm launch' },
{ key: 'showExternalPath', label: 'Show app external pack path' },
{ key: 'unzipExternal', label: 'Unzip SD15 pack from external' },
{ key: 'loadTokenizer', label: 'Load tokenizer (bundled)' },
{ key: 'testNative', label: 'Test native ONNX (NNAPI -> CPU)' },
{ key: 'inspectUnet', label: 'Inspect pack UNet' },
{ key: 'inspectText', label: 'Inspect pack text_encoder' },
{ key: 'inspectVae', label: 'Inspect pack vae_decoder' },
{ key: 'generateQuick', label: `Generate 512x512 (quick ${QUICK_STEPS})` },
{ key: 'generateQuality', label: `Generate 512x512 (quality ${QUALITY_STEPS})` },
{ key: 'backendToggle', label: 'Backend: NNAPI / CPU' },
{ key: 'importOnnx', label: 'Import ONNX files (offline)' },
{ key: 'inspectImported', label: 'Inspect first imported model' },
{ key: 'importPackZip', label: 'Import SD15 pack (.zip)' },
] as const;
type GuideStepKey = (typeof GUIDE_STEPS)[number]['key'];
export default function App() {
useKeepAwake();
const [taps, setTaps] = useState(0);
const [busy, setBusy] = useState(false);
const [provider, setProvider] = useState<string>('-');
const [output, setOutput] = useState<string>('-');
const [err, setErr] = useState<string>('');
const [status, setStatus] = useState<string>('Idle');
const [lastExportPath, setLastExportPath] = useState<string>('-');
const [importedFiles, setImportedFiles] = useState<string[]>([]);
const [packDir, setPackDir] = useState<string>('');
const [packFiles, setPackFiles] = useState<number>(0);
const [externalDir, setExternalDir] = useState<string>('');
const [prompt, setPrompt] = useState<string>('A photo of a lion in the wild, ultra realistic');
const [tokenizerReady, setTokenizerReady] = useState(false);
const [preferNnapi, setPreferNnapi] = useState(true);
const [guidedMode, setGuidedMode] = useState(true);
const [guideIndex, setGuideIndex] = useState(0);
const canRun = useMemo(() => true, []);
const guideDone = guideIndex >= GUIDE_STEPS.length;
const currentGuideStep = guideDone ? null : GUIDE_STEPS[guideIndex];
const showGuideButton = (key: GuideStepKey) => !guidedMode || guideDone || currentGuideStep?.key === key;
const completeGuideStep = (key: GuideStepKey) => {
if (!guidedMode || guideDone) return;
if (currentGuideStep?.key !== key) return;
setGuideIndex((n) => n + 1);
};
useEffect(() => {
let cancelled = false;
(async () => {
try {
const mod = (NativeModules as any)?.OrtNative;
if (!mod?.getExternalFilesDirPath) return;
const p = await mod.getExternalFilesDirPath();
if (cancelled) return;
const ext = String(p || '');
if (!ext) return;
setExternalDir((prev) => prev || ext);
if (packDir) return;
if (!mod?.statPath) return;
const candidate = `${ext.replace(/\/+$/, '')}/sd15-onnx/sd15-onnx`;
const st = await mod.statPath(`file://${candidate}/unet.onnx`);
if (cancelled) return;
if (st?.exists) setPackDir((prev) => prev || candidate);
} catch {
// Non-fatal; user can still set paths via buttons.
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const packFileUri = (filename: string) => {
const base = packDir.startsWith('file://') ? packDir.slice('file://'.length) : packDir;
const baseNoSlash = base.replace(/\/+$/, '');
return `file://${baseNoSlash}/${filename}`;
};
const tokenizer = useMemo(() => {
if (!tokenizerReady) return null;
return (globalThis as any).__sp_clip_tokenizer__ ?? null;
}, [tokenizerReady]);
const yieldToUi = async () => new Promise<void>((r) => setTimeout(r, 0));
const runGenerate = async (steps: number, forceCpu: boolean = false, lowMemoryMode: boolean = true) => {
setBusy(true);
setErr('');
setProvider('-');
setOutput('-');
try {
if (!packDir) throw new Error('Pack not set. Tap "Unzip SD15 pack from external" (once) if needed.');
if (!externalDir) throw new Error('External dir is not set. Tap "Show app external pack path" first.');
if (!tokenizer) throw new Error('Tokenizer not loaded. Tap "Load tokenizer (bundled)" first.');
const mod = (NativeModules as any)?.OrtNative;
if (!mod?.generateSd15FromIds) throw new Error('Native module OrtNative.generateSd15FromIds is not available (rebuild required).');
setStatus('Tokenizing...');
await yieldToUi();
const condIds = tokenizer.encode(prompt, 77);
const uncondIds = tokenizer.encode('', 77);
const outPath = `file://${externalDir.replace(/\/+$/, '')}/sd_out_${Date.now()}.png`;
setStatus(`Generating... (${steps} steps)`);
await yieldToUi();
const useNnapi = forceCpu ? false : preferNnapi;
const runOnce = async (nnapi: boolean, lowMem: boolean) =>
mod.generateSd15FromIds({
packDir,
condIds,
uncondIds,
steps,
guidance: 7.5,
seed: -1,
outPath,
preferNnapi: nnapi,
lowMemoryMode: lowMem,
});
let res: any;
try {
res = await runOnce(useNnapi, lowMemoryMode);
} catch (firstErr: any) {
// In v2, if NNAPI path fails without killing process, retry CPU automatically.
if (useNnapi) {
setStatus('NNAPI failed, retrying CPU...');
await yieldToUi();
res = await runOnce(false, true);
} else {
throw firstErr;
}
}
try {
if (mod?.exportImageToGallery && res?.path) {
const fileName = String(res.path).split('/').pop() || `sd_out_${Date.now()}.png`;
const exported = await mod.exportImageToGallery(String(res.path), fileName);
setLastExportPath(String(exported || `Pictures/Staticplay/${fileName}`));
}
} catch (exportErr: any) {
// Non-fatal: image remains in app external storage even if gallery export fails.
const msg = String(exportErr?.message ?? exportErr ?? '').trim();
if (msg) {
setErr((prev) => (prev ? `${prev}\nExport: ${msg}` : `Export: ${msg}`));
}
}
setProvider(String(res?.provider ?? 'unknown'));
setOutput(JSON.stringify(res));
setStatus('Done');
} catch (e: any) {
const msg = String(e?.message ?? e ?? '').trim();
setErr(msg || 'Generation failed (unknown error)');
setStatus('Error');
} finally {
setBusy(false);
}
};
return (
<SafeAreaView style={styles.safe}>
<ScrollView contentContainerStyle={styles.container} keyboardShouldPersistTaps="handled">
<Text style={styles.title}>Staticplay</Text>
<Text style={styles.sub}>Offline-first SD1 on-device (Android) - {BUILD_ID}</Text>
<View style={styles.card}>
<Text style={styles.label}>Guided mode</Text>
<Text style={styles.value}>
{guidedMode
? guideDone
? 'Done. All guided steps completed.'
: `Step ${guideIndex + 1}/${GUIDE_STEPS.length}: ${currentGuideStep?.label}`
: 'Off. Showing all buttons.'}
</Text>
<View style={styles.row}>
<Pressable style={[styles.smallBtn, styles.smallBtnPrimary]} onPress={() => setGuidedMode((v) => !v)}>
<Text style={styles.smallBtnText}>{guidedMode ? 'Show all buttons' : 'Enable guided mode'}</Text>
</Pressable>
<Pressable
style={[styles.smallBtn, styles.smallBtnGhost]}
onPress={() => {
setGuideIndex(0);
setGuidedMode(true);
}}
>
<Text style={styles.smallBtnText}>Restart guide</Text>
</Pressable>
</View>
</View>
<View style={styles.card}>
<Text style={styles.label}>Prompt</Text>
<TextInput
value={prompt}
onChangeText={setPrompt}
placeholder="Describe an image..."
placeholderTextColor="#5b6f92"
style={styles.input}
multiline
/>
<Text style={[styles.value, { marginTop: 8, color: '#8aa0c4' }]}>
512x512 - quick {QUICK_STEPS} / quality {QUALITY_STEPS} - guidance 7.5
</Text>
</View>
{showGuideButton('tapLaunch') ? (
<Pressable
style={[styles.btn, !canRun ? styles.btnDisabled : null]}
disabled={!canRun}
onPress={() => {
setTaps((n) => n + 1);
completeGuideStep('tapLaunch');
}}
>
<Text style={styles.btnText}>Tap to confirm launch ({taps})</Text>
</Pressable>
) : null}
{showGuideButton('backendToggle') ? (
<Pressable
style={[styles.btn, busy ? styles.btnDisabled : null]}
disabled={busy}
onPress={() => {
setPreferNnapi((v) => !v);
completeGuideStep('backendToggle');
}}
>
<Text style={styles.btnText}>{preferNnapi ? 'Backend: NNAPI (experimental)' : 'Backend: CPU (safe)'}</Text>
</Pressable>
) : null}
{showGuideButton('loadTokenizer') ? (
<Pressable
style={[styles.btn, busy ? styles.btnDisabled : null]}
disabled={busy}
onPress={async () => {
setBusy(true);
setErr('');
let loaded = false;
try {
setStatus('Loading tokenizer...');
const vocabAsset = Asset.fromModule(require('./assets/tokenizer/vocab.json.txt'));
const mergesAsset = Asset.fromModule(require('./assets/tokenizer/merges.txt'));
await vocabAsset.downloadAsync();
await mergesAsset.downloadAsync();
const vocabUri = vocabAsset.localUri || vocabAsset.uri;
const mergesUri = mergesAsset.localUri || mergesAsset.uri;
if (!vocabUri) throw new Error('Tokenizer vocab asset missing local URI.');
if (!mergesUri) throw new Error('Tokenizer merges asset missing local URI.');
const mod = (NativeModules as any)?.OrtNative;
const readText = mod?.readTextFile
? async (uri: string) => String(await mod.readTextFile(uri))
: async (uri: string) => String(await FileSystem.readAsStringAsync(uri));
let vocabJson = '';
let mergesTxt = '';
try {
vocabJson = await readText(vocabUri);
mergesTxt = await readText(mergesUri);
} catch {
// Fallback: read tokenizer from app external storage if bundled read fails.
if (!externalDir) throw new Error('Tokenizer read failed and external dir is not set.');
const extBase = externalDir.replace(/\/+$/, '');
vocabJson = await readText(`file://${extBase}/tokenizer/vocab.json.txt`);
mergesTxt = await readText(`file://${extBase}/tokenizer/merges.txt`);
}
(globalThis as any).__sp_clip_tokenizer__ = createClipTokenizer({ vocabJson, mergesTxt });
setTokenizerReady(true);
setStatus('OK');
loaded = true;
} catch (e: any) {
const msg = String(e?.message ?? e ?? '').trim();
setErr(msg || 'Tokenizer load failed (unknown error)');
setStatus('Error');
} finally {
setBusy(false);
if (loaded) completeGuideStep('loadTokenizer');
}
}}
>
<Text style={styles.btnText}>{busy ? 'Working...' : tokenizerReady ? 'Tokenizer loaded' : 'Load tokenizer (bundled)'}</Text>
</Pressable>
) : null}
{showGuideButton('generateQuick') ? (
<Pressable
style={[styles.btn, busy ? styles.btnDisabled : null]}
disabled={busy}
onPress={async () => {
// v2 quick path: NNAPI-first and less memory-constrained for speed.
await runGenerate(QUICK_STEPS, false, false);
completeGuideStep('generateQuick');
}}
>
<Text style={styles.btnText}>{busy ? 'Working...' : `Generate 512x512 (quick ${QUICK_STEPS})`}</Text>
</Pressable>
) : null}
{showGuideButton('generateQuality') ? (
<Pressable
style={[styles.btn, busy ? styles.btnDisabled : null]}
disabled={busy}
onPress={async () => {
// v2 quality path: keep stable settings.
await runGenerate(QUALITY_STEPS, true, true);
completeGuideStep('generateQuality');
}}
>
<Text style={styles.btnText}>{busy ? 'Working...' : `Generate 512x512 (quality ${QUALITY_STEPS})`}</Text>
</Pressable>
) : null}
{showGuideButton('testNative') ? (
<Pressable
style={[styles.btn, busy ? styles.btnDisabled : null]}
disabled={busy}
onPress={async () => {
setBusy(true);
setErr('');
setProvider('-');
setOutput('-');
setStatus('Running identity ONNX...');
try {
const mod = (NativeModules as any)?.OrtNative;
if (!mod?.runIdentity) throw new Error('Native module OrtNative is not available (rebuild required).');
const res = await mod.runIdentity([1, 2, 3]);
setProvider(String(res?.provider ?? 'unknown'));
const outArr = res?.output ?? [];
setOutput(JSON.stringify(outArr));
setStatus('OK');
} catch (e: any) {
setErr(e?.message || String(e));
setStatus('Error');
} finally {
setBusy(false);
completeGuideStep('testNative');
}
}}
>
<Text style={styles.btnText}>{busy ? 'Running...' : 'Test native ONNX (NNAPI -> CPU)'}</Text>
</Pressable>
) : null}
{showGuideButton('importOnnx') ? (
<Pressable
style={[styles.btn, busy ? styles.btnDisabled : null]}
disabled={busy}
onPress={async () => {
setBusy(true);
setErr('');
setStatus('Picking ONNX files...');
try {
const res = await DocumentPicker.getDocumentAsync({
multiple: true,
type: ['application/octet-stream', '*/*'],
copyToCacheDirectory: true,
});
if (res.canceled) return;
const destDir = `${FileSystem.documentDirectory}models/sd1/`;
await FileSystem.makeDirectoryAsync(destDir, { intermediates: true });
const saved: string[] = [];
for (const asset of res.assets) {
const name = (asset.name || 'model.onnx').replace(/[^\w.\-]+/g, '_');
const to = `${destDir}${name}`;
await FileSystem.copyAsync({ from: asset.uri, to });
saved.push(to);
}
setImportedFiles((prev) => Array.from(new Set([...saved, ...prev])));
setStatus(`Imported ${saved.length} file(s)`);
} catch (e: any) {
setErr(e?.message || String(e));
setStatus('Error');
} finally {
setBusy(false);
completeGuideStep('importOnnx');
}
}}
>
<Text style={styles.btnText}>{busy ? 'Working...' : 'Import ONNX files (offline)'}</Text>
</Pressable>
) : null}
{showGuideButton('inspectImported') ? (
<Pressable
style={[styles.btn, busy ? styles.btnDisabled : null]}
disabled={busy}
onPress={async () => {
setBusy(true);
setErr('');
setProvider('-');
setOutput('-');
setStatus('Inspecting...');
try {
if (importedFiles.length === 0) throw new Error('No imported ONNX files yet. Tap "Import ONNX files (offline)" first.');
const mod = (NativeModules as any)?.OrtNative;
if (!mod?.inspectModel) throw new Error('Native module OrtNative.inspectModel is not available (rebuild required).');
const target = importedFiles[0];
const info = await mod.inspectModel(target);
setProvider(String(info?.provider ?? 'unknown'));
setOutput(JSON.stringify({ file: target.split('/').pop(), inputs: info?.inputs, outputs: info?.outputs }));
setStatus('OK');
} catch (e: any) {
setErr(e?.message || String(e));
setStatus('Error');
} finally {
setBusy(false);
completeGuideStep('inspectImported');
}
}}
>
<Text style={styles.btnText}>Inspect first imported model</Text>
</Pressable>
) : null}
{showGuideButton('importPackZip') ? (
<Pressable
style={[styles.btn, busy ? styles.btnDisabled : null]}
disabled={busy}
onPress={async () => {
setBusy(true);
setErr('');
setStatus('Picking zip...');
try {
const res = await DocumentPicker.getDocumentAsync({
multiple: false,
type: ['application/zip', 'application/octet-stream', '*/*'],
copyToCacheDirectory: false,
});
if (res.canceled) return;
const asset = res.assets[0];
const zipUri = asset.uri;
const destDir = `${FileSystem.documentDirectory}packs/sd15-onnx/`;
await FileSystem.makeDirectoryAsync(destDir, { intermediates: true });
const mod = (NativeModules as any)?.OrtNative;
if (!mod?.unzip) throw new Error('Native module OrtNative.unzip is not available (rebuild required).');
if (mod?.statPath) {
const st = await mod.statPath(zipUri);
if (st && st.exists === false) throw new Error(`Zip not found: ${String(st.path || zipUri)}`);
}
setStatus('Unzipping... (this can take minutes)');
const out = await mod.unzip(zipUri, destDir);
{
const base = String(out?.destPath || destDir);
const normalized = base.startsWith('file://') ? base.slice('file://'.length) : base;
setPackDir(`${normalized.replace(/\/+$/, '')}/sd15-onnx`);
}
setPackFiles(Number(out?.files || 0));
setStatus(`Unzipped ${Number(out?.files || 0)} file(s)`);
} catch (e: any) {
setErr(e?.message || String(e));
setStatus('Error');
} finally {
setBusy(false);
completeGuideStep('importPackZip');
}
}}
>
<Text style={styles.btnText}>{busy ? 'Working...' : 'Import SD15 pack (.zip)'}</Text>
</Pressable>
) : null}
{showGuideButton('inspectUnet') ? (
<Pressable
style={[styles.btn, busy ? styles.btnDisabled : null]}
disabled={busy}
onPress={async () => {
setBusy(true);
setErr('');
setProvider('-');
setOutput('-');
setStatus('Inspecting UNet...');
try {
if (!packDir) throw new Error('Pack not set. Tap "Import SD15 pack (.zip)" or "Unzip SD15 pack from external" first.');
const mod = (NativeModules as any)?.OrtNative;
if (!mod?.inspectModel) throw new Error('Native module OrtNative.inspectModel is not available (rebuild required).');
const info = await mod.inspectModel(packFileUri('unet.onnx'));
setProvider(String(info?.provider ?? 'unknown'));
setOutput(JSON.stringify({ file: 'unet.onnx', inputs: info?.inputs, outputs: info?.outputs }));
setStatus('OK');
} catch (e: any) {
setErr(e?.message || String(e));
setStatus('Error');
} finally {
setBusy(false);
completeGuideStep('inspectUnet');
}
}}
>
<Text style={styles.btnText}>Inspect pack UNet</Text>
</Pressable>
) : null}
{showGuideButton('inspectText') ? (
<Pressable
style={[styles.btn, busy ? styles.btnDisabled : null]}
disabled={busy}
onPress={async () => {
setBusy(true);
setErr('');
setProvider('-');
setOutput('-');
setStatus('Inspecting text_encoder (CPU)...');
try {
if (!packDir) throw new Error('Pack not set. Tap "Import SD15 pack (.zip)" or "Unzip SD15 pack from external" first.');
const mod = (NativeModules as any)?.OrtNative;
if (!mod?.inspectModelCpu) throw new Error('Native module OrtNative.inspectModelCpu is not available (rebuild required).');
const info = await mod.inspectModelCpu(packFileUri('text_encoder.onnx'));
setProvider(String(info?.provider ?? 'unknown'));
setOutput(JSON.stringify({ file: 'text_encoder.onnx', inputs: info?.inputs, outputs: info?.outputs }));
setStatus('OK');
} catch (e: any) {
setErr(e?.message || String(e));
setStatus('Error');
} finally {
setBusy(false);
completeGuideStep('inspectText');
}
}}
>
<Text style={styles.btnText}>Inspect pack text_encoder</Text>
</Pressable>
) : null}
{showGuideButton('inspectVae') ? (
<Pressable
style={[styles.btn, busy ? styles.btnDisabled : null]}
disabled={busy}
onPress={async () => {
setBusy(true);
setErr('');
setProvider('-');
setOutput('-');
setStatus('Inspecting vae_decoder (CPU)...');
try {
if (!packDir) throw new Error('Pack not set. Tap "Import SD15 pack (.zip)" or "Unzip SD15 pack from external" first.');
const mod = (NativeModules as any)?.OrtNative;
if (!mod?.inspectModelCpu) throw new Error('Native module OrtNative.inspectModelCpu is not available (rebuild required).');
const info = await mod.inspectModelCpu(packFileUri('vae_decoder.onnx'));
setProvider(String(info?.provider ?? 'unknown'));
setOutput(JSON.stringify({ file: 'vae_decoder.onnx', inputs: info?.inputs, outputs: info?.outputs }));
setStatus('OK');
} catch (e: any) {
setErr(e?.message || String(e));
setStatus('Error');
} finally {
setBusy(false);
completeGuideStep('inspectVae');
}
}}
>
<Text style={styles.btnText}>Inspect pack vae_decoder</Text>
</Pressable>
) : null}
{showGuideButton('showExternalPath') ? (
<Pressable
style={[styles.btn, busy ? styles.btnDisabled : null]}
disabled={busy}
onPress={async () => {
setBusy(true);
setErr('');
try {
setStatus('Getting external path...');
const mod = (NativeModules as any)?.OrtNative;
if (!mod?.getExternalFilesDirPath) throw new Error('Native module OrtNative.getExternalFilesDirPath is not available (rebuild required).');
const p = await mod.getExternalFilesDirPath();
setExternalDir(String(p || ''));
setStatus('OK');
} catch (e: any) {
setErr(e?.message || String(e));
setStatus('Error');
} finally {
setBusy(false);
completeGuideStep('showExternalPath');
}
}}
>
<Text style={styles.btnText}>{busy ? 'Working...' : 'Show app external pack path'}</Text>
</Pressable>
) : null}
{showGuideButton('unzipExternal') ? (
<Pressable
style={[styles.btn, busy ? styles.btnDisabled : null]}
disabled={busy}
onPress={async () => {
setBusy(true);
setErr('');
try {
if (!externalDir) throw new Error('External dir is not set. Tap "Show app external pack path" first.');
const mod = (NativeModules as any)?.OrtNative;
if (!mod?.unzip) throw new Error('Native module OrtNative.unzip is not available (rebuild required).');
const extBase = externalDir.replace(/\/+$/, '');
const zipCandidates = [
`file://${extBase}/sd15-onnx-pack.zip`,
'file:///storage/emulated/0/Android/data/com.anonymous.staticplaysd1mobile/files/sd15-onnx-pack.zip',
'file:///storage/emulated/0/Download/sd15-onnx-pack.zip',
];
let zipUri = zipCandidates[0];
if (mod?.statPath) {
let found = '';
for (const candidate of zipCandidates) {
const st = await mod.statPath(candidate);
if (st?.exists) {
found = candidate;
break;
}
}
if (!found) throw new Error(`Zip not found in any expected location: ${zipCandidates.join(' | ')}`);
zipUri = found;
}
const destDir = `file://${externalDir.replace(/\/+$/, '')}/sd15-onnx/`;
setStatus('Unzipping... (this can take minutes)');
const out = await mod.unzip(zipUri, destDir);
{
const base = String(out?.destPath || destDir);
const normalized = base.startsWith('file://') ? base.slice('file://'.length) : base;
setPackDir(`${normalized.replace(/\/+$/, '')}/sd15-onnx`);
}
setPackFiles(Number(out?.files || 0));
setStatus(`Unzipped ${Number(out?.files || 0)} file(s)`);
} catch (e: any) {
setErr(e?.message || String(e));
setStatus('Error');
} finally {
setBusy(false);
completeGuideStep('unzipExternal');
}
}}
>
<Text style={styles.btnText}>{busy ? 'Working...' : 'Unzip SD15 pack from external'}</Text>
</Pressable>
) : null}
{busy ? <ActivityIndicator style={{ marginTop: 6 }} /> : null}
<View style={styles.card}>
<Text style={styles.label}>Status</Text>
<Text style={styles.value}>{status}</Text>
<Text style={[styles.label, { marginTop: 10 }]}>Next</Text>
<Text style={styles.value}>See docs/SD1_ON_DEVICE_PLAN.md</Text>
</View>
<View style={styles.card}>
<Text style={styles.label}>Native provider</Text>
<Text style={styles.value}>{provider}</Text>
<Text style={[styles.label, { marginTop: 10 }]}>Native output</Text>
<Text style={styles.value}>{output}</Text>
{err ? (
<>
<Text style={[styles.label, { marginTop: 10, color: '#fbbf24' }]}>Error</Text>
<Text selectable style={[styles.value, { color: '#fbbf24' }]}>
{err}
</Text>
</>
) : null}
</View>
<View style={styles.card}>
<Text style={styles.label}>Last export path</Text>
<Text style={styles.value}>{lastExportPath || '-'}</Text>
</View>
<View style={styles.card}>
<Text style={styles.label}>Imported files</Text>
<Text style={styles.value}>{importedFiles.length ? importedFiles.map((p) => p.split('/').pop()).join(', ') : '-'}</Text>
</View>
<View style={styles.card}>
<Text style={styles.label}>Pack dir</Text>
<Text style={styles.value}>{packDir || '-'}</Text>
<Text style={[styles.label, { marginTop: 10 }]}>Pack files</Text>
<Text style={styles.value}>{packDir ? String(packFiles) : '-'}</Text>
<Text style={[styles.label, { marginTop: 10 }]}>External dir</Text>
<Text style={styles.value}>{externalDir || '-'}</Text>
</View>
</ScrollView>
<StatusBar style="auto" />
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: '#0b0f19' },
container: { padding: 18, gap: 12 },
title: { color: '#eaf0ff', fontSize: 22, fontWeight: '900' },
sub: { color: '#8aa0c4', fontSize: 12 },
btn: {
backgroundColor: '#2563eb',
paddingVertical: 12,
paddingHorizontal: 14,
borderRadius: 12,
alignItems: 'center',
marginTop: 6,
},
btnDisabled: { opacity: 0.5 },
btnText: { color: 'white', fontWeight: '800' },
card: {
backgroundColor: 'rgba(0,0,0,0.25)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.09)',
borderRadius: 12,
padding: 14,
marginTop: 8,
},
label: { color: '#9db2d6', fontSize: 12 },
value: { color: '#eaf0ff', marginTop: 4 },
input: {
color: '#eaf0ff',
marginTop: 8,
padding: 10,
borderRadius: 10,
backgroundColor: 'rgba(255,255,255,0.05)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.10)',
minHeight: 80,
textAlignVertical: 'top',
},
row: {
flexDirection: 'row',
gap: 8,
marginTop: 10,
},
smallBtn: {
borderRadius: 10,
paddingVertical: 8,
paddingHorizontal: 10,
},
smallBtnPrimary: {
backgroundColor: '#1d4ed8',
},
smallBtnGhost: {
backgroundColor: '#1f2937',
borderWidth: 1,
borderColor: '#334155',
},
smallBtnText: {
color: '#eaf0ff',
fontWeight: '700',
fontSize: 12,
},
});