feat: v1.1 — highlight-weighted metering, settings modal, photo viewer, shutter sound control, cleanup

This commit is contained in:
2026-09-09 09:41:55 +07:00
parent e9a9005ef2
commit 09dbc72cce
46 changed files with 1684 additions and 2491 deletions
-14
View File
@@ -1,14 +0,0 @@
> Why do I have a folder named ".expo" in my project?
The ".expo" folder is created when an Expo project is started using "expo start" command.
> What do the files contain?
- "devices.json": contains information about devices that have recently opened this project. This is used to populate the "Development sessions" list in your development builds.
- "settings.json": contains the server configuration that is used to serve the application manifest.
- "dev/logs/": contains structured JSONL event logs from CLI commands (e.g. start.log, export.log). These are truncated on each run.
> Should I commit the ".expo" folder?
No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine.
Upon project creation, the ".expo" folder is already added to your ".gitignore" file.
File diff suppressed because it is too large Load Diff
-412
View File
@@ -1,412 +0,0 @@
{"_e":"root:init","_t":1784254504055,"format":"v0-jsonl","version":"57.0.8"}
{"_e":"env:mode","_t":1784254504056,"nodeEnv":"development","babelEnv":"development","mode":"development"}
{"_e":"env:load","_t":1784254504058,"mode":"development","files":[],"env":{}}
{"_e":"devserver:start","_t":1784254508949,"mode":"development","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":false}
{"_e":"metro:config","_t":1784254509422,"serverRoot":"../../..","projectRoot":"../../..","exporting":false,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
{"_e":"expo:dev-tools-plugin:load","_t":1784254509956,"plugins":[]}
{"_e":"metro:instantiate","_t":1784254516842,"atlas":false,"workers":3,"host":"::","port":8086}
{"_e":"metro:bundling:started","_t":1784254564715,"id":"1","platform":"android","environment":null,"entry":"index.ts"}
{"_e":"metro:bundling:progress","_t":1784254564715,"id":"1","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784254564729,"id":"1","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784254565214,"id":"1","progress":0.010000000000000002,"total":1,"current":1}
{"_e":"metro:bundling:progress","_t":1784254565318,"id":"1","progress":0.09,"total":9,"current":3}
{"_e":"metro:bundling:progress","_t":1784254565418,"id":"1","progress":0.09,"total":41,"current":7}
{"_e":"metro:bundling:progress","_t":1784254565518,"id":"1","progress":0.09,"total":46,"current":11}
{"_e":"metro:bundling:progress","_t":1784254565577,"id":"1","progress":0.09,"total":46,"current":12}
{"_e":"metro:bundling:progress","_t":1784254565690,"id":"1","progress":0.09,"total":147,"current":14}
{"_e":"metro:bundling:progress","_t":1784254565794,"id":"1","progress":0.09,"total":156,"current":24}
{"_e":"metro:bundling:progress","_t":1784254565894,"id":"1","progress":0.09,"total":157,"current":31}
{"_e":"metro:bundling:progress","_t":1784254565998,"id":"1","progress":0.09,"total":185,"current":45}
{"_e":"metro:bundling:progress","_t":1784254566099,"id":"1","progress":0.09,"total":197,"current":52}
{"_e":"metro:bundling:progress","_t":1784254566211,"id":"1","progress":0.09,"total":210,"current":59}
{"_e":"metro:bundling:progress","_t":1784254566311,"id":"1","progress":0.09,"total":223,"current":66}
{"_e":"metro:bundling:progress","_t":1784254566419,"id":"1","progress":0.09782840730462457,"total":227,"current":71}
{"_e":"metro:bundling:progress","_t":1784254566519,"id":"1","progress":0.11201999167013746,"total":245,"current":82}
{"_e":"metro:bundling:progress","_t":1784254566623,"id":"1","progress":0.15123456790123457,"total":270,"current":105}
{"_e":"metro:bundling:progress","_t":1784254566723,"id":"1","progress":0.18367346938775508,"total":287,"current":123}
{"_e":"metro:bundling:progress","_t":1784254566823,"id":"1","progress":0.2352733413538482,"total":301,"current":146}
{"_e":"metro:bundling:progress","_t":1784254566923,"id":"1","progress":0.259462890625,"total":320,"current":163}
{"_e":"metro:bundling:progress","_t":1784254567024,"id":"1","progress":0.27952464836940155,"total":331,"current":175}
{"_e":"metro:bundling:progress","_t":1784254567124,"id":"1","progress":0.33271161520688924,"total":345,"current":199}
{"_e":"metro:bundling:progress","_t":1784254567225,"id":"1","progress":0.35676406135865596,"total":370,"current":221}
{"_e":"metro:bundling:progress","_t":1784254567326,"id":"1","progress":0.37024645369120834,"total":401,"current":244}
{"_e":"metro:bundling:progress","_t":1784254567428,"id":"1","progress":0.37472705039741466,"total":428,"current":262}
{"_e":"metro:bundling:progress","_t":1784254567529,"id":"1","progress":0.3978698224852071,"total":455,"current":287}
{"_e":"metro:bundling:progress","_t":1784254567628,"id":"1","progress":0.41077662667090886,"total":479,"current":307}
{"_e":"metro:bundling:progress","_t":1784254567730,"id":"1","progress":0.42145226326742974,"total":496,"current":322}
{"_e":"metro:bundling:progress","_t":1784254567834,"id":"1","progress":0.42145226326742974,"total":536,"current":333}
{"_e":"metro:bundling:progress","_t":1784254567938,"id":"1","progress":0.42145226326742974,"total":540,"current":339}
{"_e":"metro:bundling:progress","_t":1784254568057,"id":"1","progress":0.42145226326742974,"total":543,"current":345}
{"_e":"metro:bundling:progress","_t":1784254568158,"id":"1","progress":0.42145226326742974,"total":556,"current":354}
{"_e":"metro:bundling:progress","_t":1784254568257,"id":"1","progress":0.42145226326742974,"total":556,"current":356}
{"_e":"metro:bundling:progress","_t":1784254568357,"id":"1","progress":0.42472014414228576,"total":557,"current":363}
{"_e":"metro:bundling:progress","_t":1784254568458,"id":"1","progress":0.42472014414228576,"total":571,"current":370}
{"_e":"metro:bundling:progress","_t":1784254568559,"id":"1","progress":0.44212055590581395,"total":573,"current":381}
{"_e":"metro:bundling:progress","_t":1784254568663,"id":"1","progress":0.46466158211707237,"total":578,"current":394}
{"_e":"metro:bundling:progress","_t":1784254568764,"id":"1","progress":0.48831470460153875,"total":581,"current":406}
{"_e":"metro:bundling:progress","_t":1784254568909,"id":"1","progress":0.500828402366864,"total":585,"current":414}
{"_e":"metro:bundling:progress","_t":1784254569015,"id":"1","progress":0.500828402366864,"total":2334,"current":415}
{"_e":"metro:bundling:progress","_t":1784254569115,"id":"1","progress":0.500828402366864,"total":2338,"current":435}
{"_e":"metro:bundling:progress","_t":1784254569222,"id":"1","progress":0.500828402366864,"total":2343,"current":440}
{"_e":"metro:bundling:progress","_t":1784254569326,"id":"1","progress":0.500828402366864,"total":2346,"current":459}
{"_e":"metro:bundling:progress","_t":1784254569467,"id":"1","progress":0.500828402366864,"total":2347,"current":467}
{"_e":"metro:bundling:progress","_t":1784254569567,"id":"1","progress":0.500828402366864,"total":2348,"current":472}
{"_e":"metro:bundling:progress","_t":1784254569667,"id":"1","progress":0.500828402366864,"total":2367,"current":491}
{"_e":"metro:bundling:progress","_t":1784254569771,"id":"1","progress":0.500828402366864,"total":2428,"current":523}
{"_e":"metro:bundling:progress","_t":1784254569871,"id":"1","progress":0.500828402366864,"total":2454,"current":542}
{"_e":"metro:bundling:progress","_t":1784254569971,"id":"1","progress":0.500828402366864,"total":2465,"current":563}
{"_e":"metro:bundling:progress","_t":1784254570071,"id":"1","progress":0.500828402366864,"total":2477,"current":583}
{"_e":"metro:bundling:progress","_t":1784254570173,"id":"1","progress":0.500828402366864,"total":2478,"current":625}
{"_e":"metro:bundling:progress","_t":1784254570273,"id":"1","progress":0.500828402366864,"total":2478,"current":677}
{"_e":"metro:bundling:progress","_t":1784254570374,"id":"1","progress":0.500828402366864,"total":2478,"current":727}
{"_e":"metro:bundling:progress","_t":1784254570474,"id":"1","progress":0.500828402366864,"total":2478,"current":772}
{"_e":"metro:bundling:progress","_t":1784254570574,"id":"1","progress":0.500828402366864,"total":2478,"current":818}
{"_e":"metro:bundling:progress","_t":1784254570674,"id":"1","progress":0.500828402366864,"total":2478,"current":863}
{"_e":"metro:bundling:progress","_t":1784254570774,"id":"1","progress":0.500828402366864,"total":2478,"current":912}
{"_e":"metro:bundling:progress","_t":1784254570874,"id":"1","progress":0.500828402366864,"total":2478,"current":965}
{"_e":"metro:bundling:progress","_t":1784254570974,"id":"1","progress":0.500828402366864,"total":2478,"current":1008}
{"_e":"metro:bundling:progress","_t":1784254571076,"id":"1","progress":0.500828402366864,"total":2478,"current":1056}
{"_e":"metro:bundling:progress","_t":1784254571176,"id":"1","progress":0.500828402366864,"total":2478,"current":1105}
{"_e":"metro:bundling:progress","_t":1784254571277,"id":"1","progress":0.500828402366864,"total":2478,"current":1160}
{"_e":"metro:bundling:progress","_t":1784254571377,"id":"1","progress":0.500828402366864,"total":2478,"current":1209}
{"_e":"metro:bundling:progress","_t":1784254571478,"id":"1","progress":0.500828402366864,"total":2478,"current":1264}
{"_e":"metro:bundling:progress","_t":1784254571578,"id":"1","progress":0.500828402366864,"total":2478,"current":1311}
{"_e":"metro:bundling:progress","_t":1784254571678,"id":"1","progress":0.500828402366864,"total":2478,"current":1357}
{"_e":"metro:bundling:progress","_t":1784254571778,"id":"1","progress":0.500828402366864,"total":2478,"current":1410}
{"_e":"metro:bundling:progress","_t":1784254571878,"id":"1","progress":0.500828402366864,"total":2478,"current":1461}
{"_e":"metro:bundling:progress","_t":1784254571978,"id":"1","progress":0.500828402366864,"total":2478,"current":1507}
{"_e":"metro:bundling:progress","_t":1784254572078,"id":"1","progress":0.500828402366864,"total":2478,"current":1560}
{"_e":"metro:bundling:progress","_t":1784254572178,"id":"1","progress":0.500828402366864,"total":2478,"current":1611}
{"_e":"metro:bundling:progress","_t":1784254572279,"id":"1","progress":0.500828402366864,"total":2478,"current":1668}
{"_e":"metro:bundling:progress","_t":1784254572379,"id":"1","progress":0.500828402366864,"total":2478,"current":1721}
{"_e":"metro:bundling:progress","_t":1784254572480,"id":"1","progress":0.5136689550856253,"total":2478,"current":1776}
{"_e":"metro:bundling:progress","_t":1784254572580,"id":"1","progress":0.5394363050209071,"total":2478,"current":1820}
{"_e":"metro:bundling:progress","_t":1784254572681,"id":"1","progress":0.5737542838642687,"total":2478,"current":1877}
{"_e":"metro:bundling:progress","_t":1784254572781,"id":"1","progress":0.6047290409029646,"total":2478,"current":1927}
{"_e":"metro:bundling:progress","_t":1784254572881,"id":"1","progress":0.6397417532559323,"total":2478,"current":1982}
{"_e":"metro:bundling:progress","_t":1784254572981,"id":"1","progress":0.6737507987969679,"total":2478,"current":2034}
{"_e":"metro:bundling:progress","_t":1784254573081,"id":"1","progress":0.7106802981654216,"total":2478,"current":2089}
{"_e":"metro:bundling:progress","_t":1784254573181,"id":"1","progress":0.7506914764373623,"total":2478,"current":2147}
{"_e":"metro:bundling:progress","_t":1784254573281,"id":"1","progress":0.782489458485683,"total":2478,"current":2192}
{"_e":"metro:bundling:progress","_t":1784254573382,"id":"1","progress":0.822981380620811,"total":2478,"current":2248}
{"_e":"metro:bundling:progress","_t":1784254573483,"id":"1","progress":0.8637444540202369,"total":2478,"current":2303}
{"_e":"metro:bundling:progress","_t":1784254573583,"id":"1","progress":0.8846124232278917,"total":2489,"current":2341}
{"_e":"metro:bundling:progress","_t":1784254573683,"id":"1","progress":0.8880743173619988,"total":2499,"current":2355}
{"_e":"metro:bundling:progress","_t":1784254573786,"id":"1","progress":0.8880743173619988,"total":2589,"current":2373}
{"_e":"metro:bundling:progress","_t":1784254573886,"id":"1","progress":0.8880743173619988,"total":2601,"current":2399}
{"_e":"metro:bundling:progress","_t":1784254573987,"id":"1","progress":0.8880743173619988,"total":2602,"current":2410}
{"_e":"metro:bundling:progress","_t":1784254574087,"id":"1","progress":0.8880743173619988,"total":2614,"current":2424}
{"_e":"metro:bundling:progress","_t":1784254574188,"id":"1","progress":0.8880743173619988,"total":2617,"current":2444}
{"_e":"metro:bundling:progress","_t":1784254574288,"id":"1","progress":0.8880743173619988,"total":2619,"current":2451}
{"_e":"metro:bundling:progress","_t":1784254574388,"id":"1","progress":0.8880743173619988,"total":2621,"current":2455}
{"_e":"metro:bundling:progress","_t":1784254574488,"id":"1","progress":0.8880743173619988,"total":2625,"current":2462}
{"_e":"metro:bundling:progress","_t":1784254574588,"id":"1","progress":0.8880743173619988,"total":2631,"current":2469}
{"_e":"metro:bundling:progress","_t":1784254574688,"id":"1","progress":0.8880743173619988,"total":2636,"current":2474}
{"_e":"metro:bundling:progress","_t":1784254574844,"id":"1","progress":0.8880743173619988,"total":2640,"current":2481}
{"_e":"metro:bundling:progress","_t":1784254574944,"id":"1","progress":0.8880743173619988,"total":2649,"current":2493}
{"_e":"metro:bundling:progress","_t":1784254575044,"id":"1","progress":0.8880743173619988,"total":2700,"current":2508}
{"_e":"metro:bundling:progress","_t":1784254575144,"id":"1","progress":0.8880743173619988,"total":2707,"current":2526}
{"_e":"metro:bundling:progress","_t":1784254575245,"id":"1","progress":0.8880743173619988,"total":2713,"current":2534}
{"_e":"metro:bundling:progress","_t":1784254575345,"id":"1","progress":0.8880743173619988,"total":2778,"current":2556}
{"_e":"metro:bundling:progress","_t":1784254575447,"id":"1","progress":0.8880743173619988,"total":2786,"current":2569}
{"_e":"metro:bundling:progress","_t":1784254575547,"id":"1","progress":0.8880743173619988,"total":2787,"current":2572}
{"_e":"metro:bundling:progress","_t":1784254575647,"id":"1","progress":0.8880743173619988,"total":2787,"current":2574}
{"_e":"metro:bundling:progress","_t":1784254575778,"id":"1","progress":0.8880743173619988,"total":2787,"current":2575}
{"_e":"metro:bundling:progress","_t":1784254575878,"id":"1","progress":0.8880743173619988,"total":2789,"current":2582}
{"_e":"metro:bundling:progress","_t":1784254575980,"id":"1","progress":0.8880743173619988,"total":2789,"current":2593}
{"_e":"metro:bundling:progress","_t":1784254576082,"id":"1","progress":0.8880743173619988,"total":2799,"current":2612}
{"_e":"metro:bundling:progress","_t":1784254576182,"id":"1","progress":0.8880743173619988,"total":2813,"current":2637}
{"_e":"metro:bundling:progress","_t":1784254576282,"id":"1","progress":0.8880743173619988,"total":2824,"current":2661}
{"_e":"metro:bundling:progress","_t":1784254576384,"id":"1","progress":0.9156393512217658,"total":2830,"current":2708}
{"_e":"metro:bundling:progress","_t":1784254576484,"id":"1","progress":0.9156393512217658,"total":2848,"current":2724}
{"_e":"metro:bundling:progress","_t":1784254576584,"id":"1","progress":0.9156393512217658,"total":2849,"current":2726}
{"_e":"metro:bundling:progress","_t":1784254576684,"id":"1","progress":0.9156393512217658,"total":2868,"current":2734}
{"_e":"metro:bundling:progress","_t":1784254576827,"id":"1","progress":0.9156393512217658,"total":2868,"current":2735}
{"_e":"metro:bundling:progress","_t":1784254576927,"id":"1","progress":0.9156393512217658,"total":2885,"current":2738}
{"_e":"metro:bundling:progress","_t":1784254577029,"id":"1","progress":0.9156393512217658,"total":2885,"current":2741}
{"_e":"metro:bundling:progress","_t":1784254577129,"id":"1","progress":0.9156393512217658,"total":2900,"current":2760}
{"_e":"metro:bundling:progress","_t":1784254577229,"id":"1","progress":0.9242757760075363,"total":2901,"current":2789}
{"_e":"metro:bundling:progress","_t":1784254577332,"id":"1","progress":0.936287891094404,"total":2903,"current":2809}
{"_e":"metro:bundling:progress","_t":1784254577435,"id":"1","progress":0.936287891094404,"total":2938,"current":2819}
{"_e":"metro:bundling:progress","_t":1784254577540,"id":"1","progress":0.936287891094404,"total":2986,"current":2825}
{"_e":"metro:bundling:progress","_t":1784254577642,"id":"1","progress":0.936287891094404,"total":3015,"current":2842}
{"_e":"metro:bundling:progress","_t":1784254577740,"id":"1","progress":0.936287891094404,"total":3030,"current":2856}
{"_e":"metro:bundling:progress","_t":1784254577840,"id":"1","progress":0.936287891094404,"total":3035,"current":2860}
{"_e":"metro:bundling:progress","_t":1784254577940,"id":"1","progress":0.936287891094404,"total":3044,"current":2871}
{"_e":"metro:bundling:progress","_t":1784254578041,"id":"1","progress":0.936287891094404,"total":3047,"current":2879}
{"_e":"metro:bundling:progress","_t":1784254578141,"id":"1","progress":0.936287891094404,"total":3065,"current":2890}
{"_e":"metro:bundling:progress","_t":1784254578241,"id":"1","progress":0.936287891094404,"total":3080,"current":2900}
{"_e":"metro:bundling:progress","_t":1784254578342,"id":"1","progress":0.936287891094404,"total":3082,"current":2913}
{"_e":"metro:bundling:progress","_t":1784254578442,"id":"1","progress":0.936287891094404,"total":3082,"current":2928}
{"_e":"metro:bundling:progress","_t":1784254578543,"id":"1","progress":0.936287891094404,"total":3089,"current":2949}
{"_e":"metro:bundling:progress","_t":1784254578642,"id":"1","progress":0.936287891094404,"total":3096,"current":2973}
{"_e":"metro:bundling:progress","_t":1784254578742,"id":"1","progress":0.936287891094404,"total":3114,"current":2999}
{"_e":"metro:bundling:progress","_t":1784254578843,"id":"1","progress":0.936287891094404,"total":3114,"current":3002}
{"_e":"metro:bundling:progress","_t":1784254578943,"id":"1","progress":0.936287891094404,"total":3123,"current":3009}
{"_e":"metro:bundling:progress","_t":1784254579045,"id":"1","progress":0.936287891094404,"total":3123,"current":3011}
{"_e":"metro:bundling:progress","_t":1784254579064,"id":"1","progress":0.936287891094404,"total":3123,"current":3012}
{"_e":"metro:bundling:progress","_t":1784254579164,"id":"1","progress":0.936287891094404,"total":3131,"current":3025}
{"_e":"metro:bundling:progress","_t":1784254579265,"id":"1","progress":0.9365842200881371,"total":3134,"current":3033}
{"_e":"metro:bundling:progress","_t":1784254579367,"id":"1","progress":0.9368419128824654,"total":3147,"current":3046}
{"_e":"metro:bundling:progress","_t":1784254579468,"id":"1","progress":0.9387456790123457,"total":3150,"current":3052}
{"_e":"metro:bundling:progress","_t":1784254579568,"id":"1","progress":0.9393609473418997,"total":3150,"current":3053}
{"_e":"metro:bundling:progress","_t":1784254579682,"id":"1","progress":0.9412079617031998,"total":3150,"current":3056}
{"_e":"metro:bundling:progress","_t":1784254579782,"id":"1","progress":0.9430567901234569,"total":3150,"current":3059}
{"_e":"metro:bundling:progress","_t":1784254579883,"id":"1","progress":0.9473777777777779,"total":3150,"current":3066}
{"_e":"metro:bundling:progress","_t":1784254579989,"id":"1","progress":0.9529773417570666,"total":3152,"current":3077}
{"_e":"metro:bundling:progress","_t":1784254580089,"id":"1","progress":0.9549354307189066,"total":3159,"current":3087}
{"_e":"metro:bundling:progress","_t":1784254580189,"id":"1","progress":0.9686047908988945,"total":3160,"current":3110}
{"_e":"metro:bundling:progress","_t":1784254580292,"id":"1","progress":0.9717926782024794,"total":3168,"current":3123}
{"_e":"metro:bundling:progress","_t":1784254580392,"id":"1","progress":0.9767778200247423,"total":3168,"current":3131}
{"_e":"metro:bundling:progress","_t":1784254580502,"id":"1","progress":0.9768938323842832,"total":3184,"current":3147}
{"_e":"metro:bundling:progress","_t":1784254580608,"id":"1","progress":0.9769943965134558,"total":3198,"current":3161}
{"_e":"metro:bundling:progress","_t":1784254580709,"id":"1","progress":0.9769943965134558,"total":3220,"current":3182}
{"_e":"metro:bundling:progress","_t":1784254580809,"id":"1","progress":0.9791352776121244,"total":3242,"current":3208}
{"_e":"metro:bundling:progress","_t":1784254580909,"id":"1","progress":0.9791352776121244,"total":3272,"current":3232}
{"_e":"metro:bundling:progress","_t":1784254581010,"id":"1","progress":0.9848326177487721,"total":3284,"current":3259}
{"_e":"metro:bundling:progress","_t":1784254581111,"id":"1","progress":0.999,"total":3291,"current":3290}
{"_e":"metro:bundling:progress","_t":1784254581211,"id":"1","progress":0.999,"total":3291,"current":3291}
{"_e":"metro:bundling:done","_t":1784254581468,"id":"1","total":3291,"ms":16753.428873}
{"_e":"metro:client_log","_t":1784254596888,"level":"warn","data":["SafeAreaView has been deprecated and will be removed in a future release. Please use 'react-native-safe-area-context' instead. See https://github.com/AppAndFlow/react-native-safe-area-context"]}
{"_e":"metro:bundling:started","_t":1784254612886,"id":"3","platform":"android","environment":null,"entry":"index.ts"}
{"_e":"metro:bundling:progress","_t":1784254612886,"id":"3","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:done","_t":1784254612993,"id":"3","total":1,"ms":107.061183}
{"_e":"metro:client_log","_t":1784254630240,"level":"warn","data":["SafeAreaView has been deprecated and will be removed in a future release. Please use 'react-native-safe-area-context' instead. See https://github.com/AppAndFlow/react-native-safe-area-context"]}
{"_e":"metro:client_log","_t":1784254721520,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
{"_e":"metro:client_log","_t":1784254774528,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
{"_e":"metro:client_log","_t":1784254928412,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
{"_e":"metro:bundling:started","_t":1784256200326,"id":"7","platform":"android","environment":null,"entry":"index.ts"}
{"_e":"metro:bundling:progress","_t":1784256200326,"id":"7","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784256200332,"id":"7","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784256200434,"id":"7","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:done","_t":1784256200538,"id":"7","total":1,"ms":211.987279}
{"_e":"metro:client_log","_t":1784256223781,"level":"warn","data":["SafeAreaView has been deprecated and will be removed in a future release. Please use 'react-native-safe-area-context' instead. See https://github.com/AppAndFlow/react-native-safe-area-context"]}
{"_e":"metro:client_log","_t":1784256310429,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
{"_e":"metro:client_log","_t":1784256369062,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
{"_e":"metro:client_log","_t":1784256387012,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
{"_e":"metro:client_log","_t":1784256420271,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
{"_e":"metro:bundling:started","_t":1784258596497,"id":"9","platform":"android","environment":null,"entry":"index.ts"}
{"_e":"metro:bundling:progress","_t":1784258596520,"id":"9","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784258596532,"id":"9","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784258596843,"id":"9","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784258597094,"id":"9","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784258597194,"id":"9","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784258597294,"id":"9","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784258597394,"id":"9","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784258597494,"id":"9","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784258597594,"id":"9","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784258597694,"id":"9","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784258597809,"id":"9","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784258597925,"id":"9","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784258598025,"id":"9","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:done","_t":1784258598321,"id":"9","total":1,"ms":1802.095472}
{"_e":"metro:client_log","_t":1784258647493,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
{"_e":"metro:bundling:started","_t":1784261637702,"id":"b","platform":"android","environment":null,"entry":"index.ts"}
{"_e":"metro:bundling:progress","_t":1784261637702,"id":"b","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784261637708,"id":"b","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784261637808,"id":"b","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784261638016,"id":"b","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784261638197,"id":"b","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:done","_t":1784261638447,"id":"b","total":1,"ms":744.986372}
{"_e":"metro:client_log","_t":1784261670156,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
{"_e":"metro:client_log","_t":1784261697357,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
{"_e":"metro:bundling:started","_t":1784263415011,"id":"d","platform":"android","environment":null,"entry":"index.ts"}
{"_e":"metro:bundling:progress","_t":1784263415013,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784263415016,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784263415116,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784263415858,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:done","_t":1784263416167,"id":"d","total":1,"ms":1153.731646}
{"_e":"metro:bundling:started","_t":1784266840468,"id":"f","platform":"android","environment":null,"entry":"index.ts"}
{"_e":"metro:bundling:progress","_t":1784266840469,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266840484,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266840743,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266840992,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266841361,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266841461,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266841561,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266841661,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266841761,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266841861,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266841961,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266842061,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266842173,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266842275,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266842846,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266843171,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266843304,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266843413,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266844439,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266844538,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266844818,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266844918,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266844939,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266845040,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266845141,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266845241,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266845341,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266845443,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266845546,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266845580,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:done","_t":1784266846113,"id":"f","total":1,"ms":5644.099564}
{"_e":"metro:client_log","_t":1784266851912,"level":"error","data":["[TypeError: undefined is not a function]"]}
{"_e":"root:init","_t":1784293044660,"format":"v0-jsonl","version":"57.0.8"}
{"_e":"env:mode","_t":1784293044688,"nodeEnv":"development","babelEnv":"development","mode":"development"}
{"_e":"env:load","_t":1784293044699,"mode":"development","files":[],"env":{}}
{"_e":"devserver:start","_t":1784293048866,"mode":"development","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":false}
{"_e":"metro:config","_t":1784293054547,"serverRoot":"../../..","projectRoot":"../../..","exporting":false,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
{"_e":"expo:dev-tools-plugin:load","_t":1784293056345,"plugins":[]}
{"_e":"metro:instantiate","_t":1784293066659,"atlas":false,"workers":6,"host":"::","port":8081}
{"_e":"metro:bundling:started","_t":1784293282277,"id":"1","platform":"android","environment":null,"entry":"index.ts"}
{"_e":"metro:bundling:progress","_t":1784293282278,"id":"1","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784293282305,"id":"1","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784293284527,"id":"1","progress":0.010000000000000002,"total":1,"current":1}
{"_e":"metro:bundling:progress","_t":1784293284627,"id":"1","progress":0.09,"total":9,"current":3}
{"_e":"metro:bundling:progress","_t":1784293284739,"id":"1","progress":0.25,"total":12,"current":6}
{"_e":"metro:bundling:progress","_t":1784293284907,"id":"1","progress":0.34027777777777785,"total":12,"current":7}
{"_e":"metro:bundling:progress","_t":1784293285240,"id":"1","progress":0.4444444444444444,"total":12,"current":8}
{"_e":"metro:bundling:progress","_t":1784293285340,"id":"1","progress":0.4444444444444444,"total":60,"current":12}
{"_e":"metro:bundling:progress","_t":1784293285447,"id":"1","progress":0.4444444444444444,"total":60,"current":13}
{"_e":"metro:bundling:progress","_t":1784293285550,"id":"1","progress":0.4444444444444444,"total":65,"current":19}
{"_e":"metro:bundling:progress","_t":1784293285649,"id":"1","progress":0.4444444444444444,"total":77,"current":41}
{"_e":"metro:bundling:progress","_t":1784293285750,"id":"1","progress":0.4444444444444444,"total":94,"current":52}
{"_e":"metro:bundling:progress","_t":1784293285850,"id":"1","progress":0.4444444444444444,"total":123,"current":67}
{"_e":"metro:bundling:progress","_t":1784293285950,"id":"1","progress":0.4444444444444444,"total":133,"current":83}
{"_e":"metro:bundling:progress","_t":1784293286050,"id":"1","progress":0.4444444444444444,"total":155,"current":92}
{"_e":"metro:bundling:progress","_t":1784293286155,"id":"1","progress":0.4444444444444444,"total":186,"current":105}
{"_e":"metro:bundling:progress","_t":1784293286255,"id":"1","progress":0.4444444444444444,"total":186,"current":110}
{"_e":"metro:bundling:progress","_t":1784293286355,"id":"1","progress":0.4444444444444444,"total":193,"current":119}
{"_e":"metro:bundling:progress","_t":1784293286471,"id":"1","progress":0.4444444444444444,"total":200,"current":129}
{"_e":"metro:bundling:progress","_t":1784293286534,"id":"1","progress":0.4444444444444444,"total":200,"current":130}
{"_e":"metro:bundling:progress","_t":1784293286653,"id":"1","progress":0.4444444444444444,"total":232,"current":142}
{"_e":"metro:bundling:progress","_t":1784293286753,"id":"1","progress":0.4444444444444444,"total":328,"current":149}
{"_e":"metro:bundling:progress","_t":1784293286928,"id":"1","progress":0.4444444444444444,"total":328,"current":150}
{"_e":"metro:bundling:progress","_t":1784293287051,"id":"1","progress":0.4444444444444444,"total":2077,"current":151}
{"_e":"metro:bundling:progress","_t":1784293287151,"id":"1","progress":0.4444444444444444,"total":2091,"current":183}
{"_e":"metro:bundling:progress","_t":1784293287251,"id":"1","progress":0.4444444444444444,"total":2105,"current":198}
{"_e":"metro:bundling:progress","_t":1784293287351,"id":"1","progress":0.4444444444444444,"total":2172,"current":215}
{"_e":"metro:bundling:progress","_t":1784293287465,"id":"1","progress":0.4444444444444444,"total":2225,"current":237}
{"_e":"metro:bundling:progress","_t":1784293287568,"id":"1","progress":0.4444444444444444,"total":2248,"current":253}
{"_e":"metro:bundling:progress","_t":1784293287668,"id":"1","progress":0.4444444444444444,"total":2271,"current":275}
{"_e":"metro:bundling:progress","_t":1784293287768,"id":"1","progress":0.4444444444444444,"total":2309,"current":306}
{"_e":"metro:bundling:progress","_t":1784293287868,"id":"1","progress":0.4444444444444444,"total":2322,"current":326}
{"_e":"metro:bundling:progress","_t":1784293287990,"id":"1","progress":0.4444444444444444,"total":2322,"current":327}
{"_e":"metro:bundling:progress","_t":1784293288090,"id":"1","progress":0.4444444444444444,"total":2326,"current":368}
{"_e":"metro:bundling:progress","_t":1784293288198,"id":"1","progress":0.4444444444444444,"total":2326,"current":410}
{"_e":"metro:bundling:progress","_t":1784293288305,"id":"1","progress":0.4444444444444444,"total":2326,"current":438}
{"_e":"metro:bundling:progress","_t":1784293288405,"id":"1","progress":0.4444444444444444,"total":2326,"current":466}
{"_e":"metro:bundling:progress","_t":1784293288506,"id":"1","progress":0.4444444444444444,"total":2326,"current":531}
{"_e":"metro:bundling:progress","_t":1784293288606,"id":"1","progress":0.4444444444444444,"total":2326,"current":588}
{"_e":"metro:bundling:progress","_t":1784293288707,"id":"1","progress":0.4444444444444444,"total":2326,"current":647}
{"_e":"metro:bundling:progress","_t":1784293288807,"id":"1","progress":0.4444444444444444,"total":2326,"current":713}
{"_e":"metro:bundling:progress","_t":1784293288908,"id":"1","progress":0.4444444444444444,"total":2326,"current":780}
{"_e":"metro:bundling:progress","_t":1784293289008,"id":"1","progress":0.4444444444444444,"total":2326,"current":850}
{"_e":"metro:bundling:progress","_t":1784293289108,"id":"1","progress":0.4444444444444444,"total":2326,"current":907}
{"_e":"metro:bundling:progress","_t":1784293289211,"id":"1","progress":0.4444444444444444,"total":2326,"current":969}
{"_e":"metro:bundling:progress","_t":1784293289311,"id":"1","progress":0.4444444444444444,"total":2326,"current":1040}
{"_e":"metro:bundling:progress","_t":1784293289411,"id":"1","progress":0.4444444444444444,"total":2326,"current":1108}
{"_e":"metro:bundling:progress","_t":1784293289511,"id":"1","progress":0.4444444444444444,"total":2326,"current":1179}
{"_e":"metro:bundling:progress","_t":1784293289611,"id":"1","progress":0.4444444444444444,"total":2326,"current":1239}
{"_e":"metro:bundling:progress","_t":1784293289711,"id":"1","progress":0.4444444444444444,"total":2326,"current":1307}
{"_e":"metro:bundling:progress","_t":1784293289811,"id":"1","progress":0.4444444444444444,"total":2326,"current":1375}
{"_e":"metro:bundling:progress","_t":1784293289911,"id":"1","progress":0.4444444444444444,"total":2326,"current":1447}
{"_e":"metro:bundling:progress","_t":1784293290011,"id":"1","progress":0.4444444444444444,"total":2326,"current":1508}
{"_e":"metro:bundling:progress","_t":1784293290111,"id":"1","progress":0.4614182344856344,"total":2326,"current":1580}
{"_e":"metro:bundling:progress","_t":1784293290211,"id":"1","progress":0.49289333852838557,"total":2326,"current":1633}
{"_e":"metro:bundling:progress","_t":1784293290311,"id":"1","progress":0.5347973005443715,"total":2326,"current":1701}
{"_e":"metro:bundling:progress","_t":1784293290412,"id":"1","progress":0.5738420738609268,"total":2326,"current":1762}
{"_e":"metro:bundling:progress","_t":1784293290512,"id":"1","progress":0.6115697239845065,"total":2326,"current":1819}
{"_e":"metro:bundling:progress","_t":1784293290613,"id":"1","progress":0.6665465865327388,"total":2326,"current":1899}
{"_e":"metro:bundling:progress","_t":1784293290713,"id":"1","progress":0.7100561967633445,"total":2326,"current":1960}
{"_e":"metro:bundling:progress","_t":1784293290813,"id":"1","progress":0.7594305724883536,"total":2326,"current":2027}
{"_e":"metro:bundling:progress","_t":1784293290913,"id":"1","progress":0.7974657485960396,"total":2346,"current":2095}
{"_e":"metro:bundling:progress","_t":1784293291013,"id":"1","progress":0.7974657485960396,"total":2415,"current":2124}
{"_e":"metro:bundling:progress","_t":1784293291115,"id":"1","progress":0.7974657485960396,"total":2472,"current":2162}
{"_e":"metro:bundling:progress","_t":1784293291215,"id":"1","progress":0.7974657485960396,"total":2474,"current":2174}
{"_e":"metro:bundling:progress","_t":1784293291315,"id":"1","progress":0.7974657485960396,"total":2519,"current":2215}
{"_e":"metro:bundling:progress","_t":1784293291416,"id":"1","progress":0.7974657485960396,"total":2536,"current":2243}
{"_e":"metro:bundling:progress","_t":1784293291519,"id":"1","progress":0.7974657485960396,"total":2569,"current":2280}
{"_e":"metro:bundling:progress","_t":1784293291628,"id":"1","progress":0.7974657485960396,"total":2606,"current":2318}
{"_e":"metro:bundling:progress","_t":1784293291728,"id":"1","progress":0.7974657485960396,"total":2659,"current":2329}
{"_e":"metro:bundling:progress","_t":1784293291831,"id":"1","progress":0.7974657485960396,"total":2675,"current":2348}
{"_e":"metro:bundling:progress","_t":1784293291931,"id":"1","progress":0.7974657485960396,"total":2687,"current":2378}
{"_e":"metro:bundling:progress","_t":1784293292033,"id":"1","progress":0.7974657485960396,"total":2707,"current":2408}
{"_e":"metro:bundling:progress","_t":1784293292133,"id":"1","progress":0.8159888890401819,"total":2710,"current":2448}
{"_e":"metro:bundling:progress","_t":1784293292234,"id":"1","progress":0.8444485836968604,"total":2714,"current":2494}
{"_e":"metro:bundling:progress","_t":1784293292333,"id":"1","progress":0.864934254482803,"total":2715,"current":2525}
{"_e":"metro:bundling:progress","_t":1784293292433,"id":"1","progress":0.8762830037348633,"total":2723,"current":2549}
{"_e":"metro:bundling:progress","_t":1784293292536,"id":"1","progress":0.8864205287942541,"total":2735,"current":2575}
{"_e":"metro:bundling:progress","_t":1784293292636,"id":"1","progress":0.8979424496100379,"total":2748,"current":2604}
{"_e":"metro:bundling:progress","_t":1784293292736,"id":"1","progress":0.9052557235874816,"total":2760,"current":2626}
{"_e":"metro:bundling:progress","_t":1784293292836,"id":"1","progress":0.9052557235874816,"total":2779,"current":2637}
{"_e":"metro:bundling:progress","_t":1784293292936,"id":"1","progress":0.9052557235874816,"total":2808,"current":2645}
{"_e":"metro:bundling:progress","_t":1784293293039,"id":"1","progress":0.9052557235874816,"total":2817,"current":2655}
{"_e":"metro:bundling:progress","_t":1784293293138,"id":"1","progress":0.9052557235874816,"total":2833,"current":2669}
{"_e":"metro:bundling:progress","_t":1784293293239,"id":"1","progress":0.9052557235874816,"total":2843,"current":2686}
{"_e":"metro:bundling:progress","_t":1784293293339,"id":"1","progress":0.9052557235874816,"total":2851,"current":2705}
{"_e":"metro:bundling:progress","_t":1784293293439,"id":"1","progress":0.9066178148700104,"total":2864,"current":2727}
{"_e":"metro:bundling:progress","_t":1784293293541,"id":"1","progress":0.9148469566887284,"total":2872,"current":2747}
{"_e":"metro:bundling:progress","_t":1784293293641,"id":"1","progress":0.9148469566887284,"total":2901,"current":2764}
{"_e":"metro:bundling:progress","_t":1784293293742,"id":"1","progress":0.9148469566887284,"total":2901,"current":2767}
{"_e":"metro:bundling:progress","_t":1784293293842,"id":"1","progress":0.9148469566887284,"total":2913,"current":2773}
{"_e":"metro:bundling:progress","_t":1784293293942,"id":"1","progress":0.9148469566887284,"total":2914,"current":2775}
{"_e":"metro:bundling:progress","_t":1784293294081,"id":"1","progress":0.9148469566887284,"total":2915,"current":2779}
{"_e":"metro:bundling:progress","_t":1784293294182,"id":"1","progress":0.9148469566887284,"total":2928,"current":2791}
{"_e":"metro:bundling:progress","_t":1784293294282,"id":"1","progress":0.9148469566887284,"total":2949,"current":2805}
{"_e":"metro:bundling:progress","_t":1784293294382,"id":"1","progress":0.9148469566887284,"total":2955,"current":2815}
{"_e":"metro:bundling:progress","_t":1784293294482,"id":"1","progress":0.9148469566887284,"total":2998,"current":2824}
{"_e":"metro:bundling:progress","_t":1784293294582,"id":"1","progress":0.9148469566887284,"total":3048,"current":2844}
{"_e":"metro:bundling:progress","_t":1784293294682,"id":"1","progress":0.9148469566887284,"total":3050,"current":2853}
{"_e":"metro:bundling:progress","_t":1784293294783,"id":"1","progress":0.9148469566887284,"total":3071,"current":2877}
{"_e":"metro:bundling:progress","_t":1784293294884,"id":"1","progress":0.9148469566887284,"total":3091,"current":2891}
{"_e":"metro:bundling:progress","_t":1784293294988,"id":"1","progress":0.9148469566887284,"total":3097,"current":2900}
{"_e":"metro:bundling:progress","_t":1784293295088,"id":"1","progress":0.9148469566887284,"total":3108,"current":2914}
{"_e":"metro:bundling:progress","_t":1784293295188,"id":"1","progress":0.9148469566887284,"total":3121,"current":2926}
{"_e":"metro:bundling:progress","_t":1784293295288,"id":"1","progress":0.9148469566887284,"total":3123,"current":2935}
{"_e":"metro:bundling:progress","_t":1784293295389,"id":"1","progress":0.9148469566887284,"total":3123,"current":2937}
{"_e":"metro:bundling:progress","_t":1784293295489,"id":"1","progress":0.9148469566887284,"total":3123,"current":2943}
{"_e":"metro:bundling:progress","_t":1784293295589,"id":"1","progress":0.9148469566887284,"total":3124,"current":2952}
{"_e":"metro:bundling:progress","_t":1784293295689,"id":"1","progress":0.9148469566887284,"total":3125,"current":2974}
{"_e":"metro:bundling:progress","_t":1784293295789,"id":"1","progress":0.9148469566887284,"total":3125,"current":2987}
{"_e":"metro:bundling:progress","_t":1784293295892,"id":"1","progress":0.9198330727820985,"total":3128,"current":3000}
{"_e":"metro:bundling:progress","_t":1784293295992,"id":"1","progress":0.9299565847185871,"total":3141,"current":3029}
{"_e":"metro:bundling:progress","_t":1784293296094,"id":"1","progress":0.9492962002534049,"total":3154,"current":3073}
{"_e":"metro:bundling:progress","_t":1784293296194,"id":"1","progress":0.9629720079436931,"total":3157,"current":3098}
{"_e":"metro:bundling:progress","_t":1784293296294,"id":"1","progress":0.9629720079436931,"total":3173,"current":3108}
{"_e":"metro:bundling:progress","_t":1784293296394,"id":"1","progress":0.9629720079436931,"total":3185,"current":3124}
{"_e":"metro:bundling:progress","_t":1784293296494,"id":"1","progress":0.9648012926893179,"total":3210,"current":3153}
{"_e":"metro:bundling:progress","_t":1784293296594,"id":"1","progress":0.9747663656275425,"total":3229,"current":3188}
{"_e":"metro:bundling:progress","_t":1784293296694,"id":"1","progress":0.9834384092814489,"total":3247,"current":3220}
{"_e":"metro:bundling:progress","_t":1784293296794,"id":"1","progress":0.9834384092814489,"total":3286,"current":3254}
{"_e":"metro:bundling:progress","_t":1784293296903,"id":"1","progress":0.999,"total":3300,"current":3300}
{"_e":"metro:bundling:done","_t":1784293297284,"id":"1","total":3300,"ms":15006.607121}
{"_e":"metro:client_log","_t":1784293311557,"level":"error","data":["[Error: [Reanimated] Failed to find host instance for a ref.]","\n\n\u001b[1mCall Stack\u001b[22m\n\u001b[2m\u001b[90m SkiaPictureView (\u001b]8;;<anonymous>\u0007<anonymous>\u001b]8;;\u0007)\u001b[39m\u001b[22m\n\u001b[2m\u001b[90m Canvas (\u001b]8;;http:/192.168.1.25:8081/index.ts.bundle\u0007http:/192.168.1.25:8081/index.ts.bundle\u001b]8;;\u0007)\u001b[39m\u001b[22m"]}
{"_e":"metro:bundling:started","_t":1784294061559,"id":"9","platform":"android","environment":null,"entry":"index.ts"}
{"_e":"metro:bundling:progress","_t":1784294061559,"id":"9","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:done","_t":1784294061634,"id":"9","total":1,"ms":75.165257}
{"_e":"metro:bundling:started","_t":1784295273557,"id":"b","platform":"android","environment":null,"entry":"index.ts"}
{"_e":"metro:bundling:progress","_t":1784295273557,"id":"b","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784295273562,"id":"b","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:done","_t":1784295273724,"id":"b","total":1,"ms":167.330607}
{"_e":"metro:bundling:started","_t":1784296589113,"id":"d","platform":"android","environment":null,"entry":"index.ts"}
{"_e":"metro:bundling:progress","_t":1784296589113,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784296589124,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784296589255,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784296589338,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784296589438,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784296589538,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784296589638,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784296589738,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784296589852,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784296589952,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784296590056,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784296590156,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784296590256,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784296590304,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:done","_t":1784296590410,"id":"d","total":1,"ms":1297.260922}
{"_e":"metro:bundling:started","_t":1784296639397,"id":"e","platform":"android","environment":null,"entry":"index.ts"}
{"_e":"metro:bundling:progress","_t":1784296639397,"id":"e","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:done","_t":1784296639495,"id":"e","total":1,"ms":97.436217}
{"_e":"metro:bundling:started","_t":1784299195451,"id":"f","platform":"android","environment":null,"entry":"index.ts"}
{"_e":"metro:bundling:progress","_t":1784299195476,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784299195479,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:done","_t":1784299196005,"id":"f","total":1,"ms":529.887212}
-8
View File
@@ -1,8 +0,0 @@
{
"devices": [
{
"installationId": "3ac39ffb-519e-4fcd-b686-c20d0a63c449",
"lastUsed": 1784299195276
}
]
}
+1
View File
@@ -1 +1,2 @@
node_modules
.expo/
+236 -48
View File
@@ -13,23 +13,22 @@ import Viewfinder, { type ViewfinderHandle } from './src/components/Viewfinder';
import AdjustmentPanel from './src/components/AdjustmentPanel';
import CameraControls from './src/components/CameraControls';
import ToolRail, { TabId } from './src/components/ToolRail';
import PreviewModal from './src/components/PreviewModal';
import SettingsModal from './src/components/SettingsModal';
import PhotoViewerModal from './src/components/PhotoViewerModal';
import { Recipe, ColorAdjustments, FrameId, GPSInfo, AspectRatio } from './src/types';
import { Recipe, ColorAdjustments, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound } from './src/types';
import { getAllRecipes, saveCustomRecipe, deleteCustomRecipe } from './src/utils/storageUtils';
import { DEFAULT_ADJUSTMENTS } from './src/utils/defaultRecipes';
import { getCurrentGPS, requestLocationPermissions } from './src/utils/locationUtils';
import { resolvePhotoGPS } from './src/utils/photoGps';
import { processAndExportPhoto } from './src/utils/exportEngine';
import { PARAM_DEFS } from './src/utils/paramDefs';
// @ts-ignore
import './global.css';
const SESSION_KEY = 'saved_session';
// DEBUG-4x3 (remove after diagnosis): build marker — prints once at JS eval.
console.error('[camdebug-ver] debug build console.error path');
export default function App() {
const [mode, setMode] = useState<'camera' | 'library'>('camera');
// Imperative handle into the Viewfinder camera controller: flushes the EV to
@@ -48,14 +47,53 @@ export default function App() {
// Composition aspect-ratio (FULL / 4:3 / 3:2 upright) + RAW DNG sidecar.
const [aspectRatio, setAspectRatio] = useState<AspectRatio>('full');
const [rawEnabled, setRawEnabled] = useState(false);
// Full-frame metering mode (FOCUS → METERING MODE): highlight-weighted meters
// the BRIGHTEST area to protect its detail (never blow whites) and pushes the
// shadows very deep — extreme contrast (GR/Sony). Realised as: a small EV dip
// (capture headroom; NOT a full-frame dim), a highlight roll (keep bright
// detail), deep shadow crush and raised contrast/clarity. All constants are a
// starting approximation — tune after device testing.
const [meterMode, setMeterMode] = useState<MeterMode>('evaluative');
const METER_HL_EV = -0.5;
const METER_HL_HIGHLIGHT = -3;
const METER_HL_SHADOW = -4;
const METER_HL_CONTRAST = 3;
const METER_HL_CLARITY = 3;
const clampAdj = (v: number) => Math.max(-10, Math.min(10, v));
const meteringActive = mode === 'camera' && meterMode === 'highlight';
const meteringAdj: Partial<ColorAdjustments> | undefined = meteringActive
? {
highlight: clampAdj((adjustments.highlight ?? 0) + METER_HL_HIGHLIGHT),
shadow: clampAdj((adjustments.shadow ?? 0) + METER_HL_SHADOW),
contrast: clampAdj((adjustments.contrast ?? 0) + METER_HL_CONTRAST),
clarity: clampAdj((adjustments.clarity ?? 0) + METER_HL_CLARITY),
}
: undefined;
// Same tonal overlay for the camera EXPORT (the EV dip is not inside — the
// hardware AE bias at capture already carries it, evFromCamera skips software
// EV, so folding the dip here would darken twice).
const renderAdj: ColorAdjustments = meteringAdj ? { ...adjustments, ...meteringAdj } : adjustments;
const rawFailRef = useRef(false);
// Progressive disclosure: a tool chip opens its panel; null = photo hero only.
const [openTab, setOpenTab] = useState<TabId | null>(null);
// The parameter whose slider row is open inside the panel — lifted here (not
// local to AdjustmentPanel) because an open row arms drag-anywhere-on-the-
// image adjustment in the Viewfinder. Cleared when the panel/tab closes.
const [openParamKey, setOpenParamKey] = useState<string | null>(null);
const [libraryImageUri, setLibraryImageUri] = useState<string | null>(null);
const [lastPhotoUri, setLastPhotoUri] = useState<string | null>(null);
const [lastSavedToLibrary, setLastSavedToLibrary] = useState(false);
const [previewVisible, setPreviewVisible] = useState(false);
// Session photo history (camera captures + library exports). The camera
// thumbnail/preview button shows the newest entry; the swipe viewer walks the
// whole list. Not persisted across restarts — entries are app-cache files.
const [photoHistory, setPhotoHistory] = useState<PhotoEntry[]>([]);
// Index into photoHistory of the photo shown in the swipe viewer; null = closed.
const [viewerIndex, setViewerIndex] = useState<number | null>(null);
// Gear sheet (sound / ratio / metering mode). Camera captures never auto-open
// the viewer anymore — the thumbnail button does.
const [settingsVisible, setSettingsVisible] = useState(false);
// Shutter click: only OFF/ON (no volume API on Android). Applied at capture.
const [shutterSound, setShutterSound] = useState<ShutterSound>('on');
const photoSeq = useRef(0);
const [isProcessing, setIsProcessing] = useState(false);
// Hardware Permissions (vision-camera)
@@ -96,6 +134,8 @@ export default function App() {
selectedFrame,
useGeotag,
aspectRatio,
meterMode,
shutterSound,
selectedRecipeId: selectedRecipe?.id || null,
gpsInfo,
};
@@ -107,7 +147,7 @@ export default function App() {
}
});
return () => subscription.remove();
}, [mode, libraryImageUri, adjustments, selectedFrame, useGeotag, selectedRecipe, gpsInfo, aspectRatio]);
}, [mode, libraryImageUri, adjustments, selectedFrame, useGeotag, selectedRecipe, gpsInfo, aspectRatio, meterMode, shutterSound]);
const initSession = async () => {
const list = await getAllRecipes();
@@ -144,6 +184,8 @@ export default function App() {
if (session.selectedFrame) setSelectedFrame(session.selectedFrame);
if (session.useGeotag !== undefined) setUseGeotag(session.useGeotag);
if (session.aspectRatio === '4:3' || session.aspectRatio === '3:2') setAspectRatio(session.aspectRatio);
if (session.meterMode === 'evaluative' || session.meterMode === 'highlight') setMeterMode(session.meterMode);
if (session.shutterSound === 'on' || session.shutterSound === 'off') setShutterSound(session.shutterSound);
if (session.gpsInfo) setGpsInfo(session.gpsInfo);
if (session.selectedRecipeId) {
restoredRecipe = list.find((r: Recipe) => r.id === session.selectedRecipeId) || null;
@@ -172,6 +214,13 @@ export default function App() {
};
const handleUpdateAdjustments = (updates: Partial<ColorAdjustments>) => {
// Storm guard, generic across every param: same-value writes (slider
// micro-jitter / repeated pointer-move events) still build a fresh
// adjustments object and re-render the whole tree — skip them. Supersedes
// the old EV-only dedupe; EV flush at capture reads its own ref, so a
// skipped identical EV needs no re-send.
const keys = Object.keys(updates) as (keyof ColorAdjustments)[];
if (keys.every((k) => updates[k] === adjustments[k])) return;
// B8: never call a setState inside another setState's updater.
const next = { ...adjustments, ...updates };
setAdjustments(next);
@@ -214,9 +263,6 @@ export default function App() {
const handleAspectRatio = (ratio: AspectRatio) => {
setAspectRatio(ratio);
if (openTab === 'ratio') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
};
const handleRawEnabled = (enabled: boolean) => {
@@ -236,7 +282,7 @@ export default function App() {
// in that state, drop RAW (removing the output reconfigures back to the
// single working JPEG session) instead of leaving a dead camera.
const handleCameraSessionError = useCallback((error: Error) => {
console.error('[camera-session] error:', error);
console.error('Camera session error:', error);
if (rawEnabledRef.current && !rawFailRef.current) {
rawFailRef.current = true;
setRawEnabled(false);
@@ -264,7 +310,6 @@ export default function App() {
} else {
await MediaLibraryLegacy.createAlbumAsync(albumName, asset);
}
console.error(`[raw] saved DNG to album ${albumName}`);
return true;
} catch (e) {
console.error('RAW album save failed:', e);
@@ -287,6 +332,16 @@ export default function App() {
Alert.alert('Recipe Saved', `"${name}" has been created.`);
};
// PRESETS → CREATE quick-input: build a brand-new recipe from the modal's
// typed fields, persist it, and apply it (the user sees the look immediately).
const handleCreateRecipe = async (recipe: Omit<Recipe, 'id' | 'isCustom'>) => {
const newRecipe = await saveCustomRecipe(recipe);
const list = await getAllRecipes();
setRecipes(list);
applyRecipe(newRecipe);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
};
const handleDeleteRecipe = async (id: string) => {
await deleteCustomRecipe(id);
const list = await getAllRecipes();
@@ -309,10 +364,9 @@ export default function App() {
try {
const perm = await MediaLibrary.requestPermissionsAsync(false, ['photo']);
mediaOk = perm.granted;
console.error(`[gps] pre-pick media+location permission granted=${perm.granted}`);
} catch (e) {
mediaOk = false;
console.error('[gps] pre-pick media permission error: ' + String(e).slice(0, 120));
console.warn('Pre-pick media+photo-location permission error:', e);
}
}
@@ -333,7 +387,6 @@ export default function App() {
if (!result.canceled && result.assets && result.assets.length > 0) {
const asset = result.assets[0];
console.error(`[gps] picked uri=${asset.uri} assetId=${asset.assetId || 'null'} exifGPS=${asset.exif ? JSON.stringify(asset.exif.GPSLatitude ?? null) : 'no-exif'}`);
setLibraryImageUri(asset.uri);
setMode('library');
// Clear old GPS — will be replaced if the photo (or device) has location data
@@ -351,9 +404,6 @@ export default function App() {
if (permissionDenied) {
console.warn('Media library read permission denied — photo GPS unavailable.');
}
console.error(
`[gps] resolved ${gps ? `src=${gps.source} lat=${gps.latitude.toFixed(5)} lon=${gps.longitude.toFixed(5)} loc=${(gps.locality || '').slice(0, 40)}` : 'none'}`
);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
@@ -369,6 +419,9 @@ export default function App() {
useGeotag,
};
// Thumbnail/preview target for the camera-mode right button: newest photo.
const lastPhotoUri = photoHistory.length > 0 ? photoHistory[photoHistory.length - 1].uri : null;
const handleCapture = async () => {
if (isProcessing) return;
@@ -403,10 +456,9 @@ export default function App() {
try {
await viewfinderRef.current?.flushExposureBias();
} catch (e) {
console.error('[ev] flush at capture failed: ' + String(e));
console.warn('EV flush at capture failed:', e);
}
const { filePath } = await photoOutput.capturePhotoToFile({}, {});
console.error(`[capture] shot-took ${Date.now() - tShot}ms`);
const { filePath } = await photoOutput.capturePhotoToFile({ enableShutterSound: shutterSound === 'on' }, {});
const sourceUri = toUri(filePath);
// RAW DNG sidecar: fire the second exposure immediately after the JPEG
@@ -414,9 +466,7 @@ export default function App() {
// "RAW" album without blocking the JPEG export below.
if (rawEnabled && rawPhotoOutput) {
try {
const tRaw = Date.now();
const rawFile = await rawPhotoOutput.capturePhotoToFile({}, {});
console.error(`[capture] raw-shot-took ${Date.now() - tRaw}ms`);
const rawFile = await rawPhotoOutput.capturePhotoToFile({ enableShutterSound: shutterSound === 'on' }, {});
const rawUri = toUri(rawFile.filePath);
saveRawToAlbum(rawUri).then((ok) => {
if (!ok) {
@@ -431,7 +481,7 @@ export default function App() {
const tExp = Date.now();
const result = await processAndExportPhoto(
sourceUri,
{ ...activeRecipe, adjustments, frameId: selectedFrame, useGeotag },
{ ...activeRecipe, adjustments: renderAdj, frameId: selectedFrame, useGeotag },
selectedFrame,
useGeotag,
captureGps,
@@ -445,12 +495,16 @@ export default function App() {
aspect: aspectRatio === 'full' ? undefined : aspectRatio,
}
);
console.error(`[capture] export-took ${Date.now() - tExp}ms (pipeline total ${Date.now() - tShot}ms)`);
if (result) {
setLastPhotoUri(result.uri);
setLastSavedToLibrary(result.savedToLibrary);
setPreviewVisible(true);
// No auto preview after a capture — the thumbnail button opens the
// swipe viewer. Saved to session history either way.
pushPhoto({
uri: result.uri,
recipeName: activeRecipe.name,
savedToLibrary: result.savedToLibrary,
assetId: result.assetId,
});
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} else {
Alert.alert('Failed', 'Error occurred while processing the captured photo.');
@@ -491,9 +545,15 @@ export default function App() {
setIsProcessing(false);
if (result) {
setLastPhotoUri(result.uri);
setLastSavedToLibrary(result.savedToLibrary);
setPreviewVisible(true);
pushPhoto({
uri: result.uri,
recipeName: activeRecipe.name,
savedToLibrary: result.savedToLibrary,
assetId: result.assetId,
});
// Library has no thumbnail button (right button switches back to camera),
// so the export result auto-opens the viewer on the newest photo.
setViewerIndex(photoHistory.length);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} else {
Alert.alert('Failed', 'Error occurred while processing image.');
@@ -509,6 +569,115 @@ export default function App() {
setMode(next);
};
// Append a finished export (camera capture or library export) to the session
// photo history. Returned entry id lets callers delete by id.
const pushPhoto = (p: Omit<PhotoEntry, 'id'>): string => {
const id = `photo-${Date.now()}-${photoSeq.current++}`;
setPhotoHistory((h) => [...h, { id, ...p }]);
return id;
};
// Thumbnail (camera mode) opens the viewer on the newest photo.
const openLatestViewer = () => {
if (photoHistory.length > 0) setViewerIndex(photoHistory.length - 1);
};
// Viewer trash: delete the photo from the app AND its gallery copy (when it
// was saved). Deleting a saved photo goes through Android's per-item system
// confirmation (scoped storage — every single delete asks again by design);
// if the user cancels THAT dialog the photo is kept in both places.
const handleDeletePhoto = async (id: string) => {
const idx = photoHistory.findIndex((p) => p.id === id);
if (idx < 0) return;
const entry = photoHistory[idx];
const remove = () => {
const next = photoHistory.filter((p) => p.id !== id);
setPhotoHistory(next);
setViewerIndex((prev) => {
if (prev === null) return null;
if (next.length === 0) return null;
if (prev > idx) return prev - 1;
if (prev >= next.length) return next.length - 1;
return prev;
});
};
const deleteCache = async () => {
try {
await FileSystem.deleteAsync(entry.uri, { idempotent: true });
} catch (e) {
console.warn('Photo cache delete failed:', e);
}
};
if (!entry.savedToLibrary || !entry.assetId) {
// Never reached the gallery — nothing for the OS to confirm.
remove();
deleteCache();
return;
}
try {
// deleteAssetsAsync shows the OS delete confirmation when the photo is
// in the gallery. false = user cancelled it → keep everything.
const ok = await MediaLibraryLegacy.deleteAssetsAsync([entry.assetId]);
if (!ok) {
Alert.alert('Photo Kept', 'Android did not confirm the deletion, so the photo stays here and in your gallery.');
return;
}
remove();
deleteCache();
} catch (e) {
console.warn('Gallery delete failed:', e);
Alert.alert('Delete Failed', 'Could not remove the photo from your gallery. It stays here too.');
}
};
// Viewer SAVE & DONE: photo was exported to cache only — copy it into the
// gallery now and remember its assetId so a later trash can remove it.
const handleSavePhoto = async (id: string) => {
const entry = photoHistory.find((p) => p.id === id);
if (!entry || entry.savedToLibrary) return;
const perm = await MediaLibraryLegacy.requestPermissionsAsync();
if (!perm.granted) {
Alert.alert('Not Saved', 'Media library access was denied, so this photo was not saved to your gallery.');
return;
}
try {
const asset = await MediaLibraryLegacy.createAssetAsync(entry.uri);
setPhotoHistory((h) => h.map((p) => (p.id === id ? { ...p, savedToLibrary: true, assetId: asset.id } : p)));
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} catch (e) {
console.error('Save to gallery failed:', e);
Alert.alert('Save Failed', 'Could not save this photo to your gallery.');
}
};
// Tool-rail toggle: closing the panel (or switching tabs) also drops the open
// parameter row — otherwise a stale slider row (and its image-drag target)
// would survive into the next panel.
const handleToggleTab = (tab: TabId) => {
setOpenTab((prev) => (prev === tab ? null : tab));
setOpenParamKey(null);
};
// While a panel parameter row is open, a vertical drag anywhere on the live
// camera image adjusts that same parameter (the open slider follows). Open
// row wins over the locked-AE EV drag; a plain tap still locks focus.
const imageAdjustTarget = (() => {
if (mode !== 'camera' || openTab !== 'iq' && openTab !== 'wb' && openTab !== 'filters') return null;
if (!openParamKey) return null;
const list = openTab === 'iq' ? PARAM_DEFS.iq : openTab === 'wb' ? PARAM_DEFS.wb : PARAM_DEFS.filters;
const def = list.find((d) => d.key === openParamKey);
if (!def) return null;
return {
key: def.key,
min: def.min,
max: def.max,
step: def.step,
value: def.get(adjustments),
onChange: (v: number) => handleUpdateAdjustments(def.set(v)),
};
})();
return (
<View className="flex-1 bg-black">
<StatusBar barStyle="light-content" translucent backgroundColor="transparent" />
@@ -540,6 +709,9 @@ export default function App() {
aspectRatio={aspectRatio}
onSessionError={handleCameraSessionError}
onExposureChange={(ev) => handleUpdateAdjustments({ exposureCompensation: ev })}
meteringEvOffset={meteringActive ? METER_HL_EV : 0}
meteringAdjustments={meteringAdj}
imageAdjustTarget={imageAdjustTarget}
/>
</View>
@@ -564,11 +736,10 @@ export default function App() {
adjustments={adjustments}
selectedFrame={selectedFrame}
useGeotag={useGeotag}
aspectRatio={aspectRatio}
rawEnabled={rawEnabled}
cameraMode={mode === 'camera'}
onAspectRatio={handleAspectRatio}
onRawEnabled={handleRawEnabled}
openParam={openParamKey}
onOpenParamChange={setOpenParamKey}
currentBaseFilter={selectedRecipe?.baseFilter}
onCreateRecipe={handleCreateRecipe}
onSelectRecipe={applyRecipe}
onUpdateAdjustments={handleUpdateAdjustments}
onUpdateFrame={handleUpdateFrame}
@@ -579,25 +750,42 @@ export default function App() {
</View>
)}
<ToolRail activeTab={openTab} onToggle={(tab) => setOpenTab(openTab === tab ? null : tab)} />
<ToolRail activeTab={openTab} onToggle={handleToggleTab} />
<CameraControls
lastPhotoUri={lastPhotoUri}
onCapture={handleCapture}
onPickImage={handlePickImage}
onOpenPreview={() => setPreviewVisible(true)}
onOpenPreview={openLatestViewer}
onOpenSettings={() => setSettingsVisible(true)}
isLibraryMode={mode === 'library'}
onSwitchToCamera={() => handleSetMode('camera')}
/>
</View>
{/* Preview Modal */}
<PreviewModal
visible={previewVisible}
onClose={() => setPreviewVisible(false)}
photoUri={lastPhotoUri}
recipeName={selectedRecipe?.name || 'Custom'}
savedToLibrary={lastSavedToLibrary}
{/* Gear sheet: shutter sound / ratio+RAW / metering mode */}
<SettingsModal
visible={settingsVisible}
onClose={() => setSettingsVisible(false)}
aspectRatio={aspectRatio}
onAspectRatio={handleAspectRatio}
rawEnabled={rawEnabled}
onRawEnabled={handleRawEnabled}
cameraMode={mode === 'camera'}
meterMode={meterMode}
onMeterModeChange={setMeterMode}
shutterSound={shutterSound}
onShutterSound={setShutterSound}
/>
{/* Session photo viewer (swipe across captured/exported photos) */}
<PhotoViewerModal
visible={viewerIndex !== null && photoHistory.length > 0}
photos={photoHistory}
initialIndex={viewerIndex ?? 0}
onClose={() => setViewerIndex(null)}
onDelete={handleDeletePhoto}
onSave={handleSavePhoto}
/>
{/* Global Processing Loader Overlay */}
+145 -103
View File
@@ -3,8 +3,11 @@ import { View, Text, TouchableOpacity, ScrollView } from 'react-native';
import Slider from '@react-native-community/slider';
import * as Haptics from 'expo-haptics';
import { Trash2 } from 'lucide-react-native';
import { Recipe, ColorAdjustments, FrameId, AspectRatio } from '../types';
import { Recipe, ColorAdjustments, FrameId, BaseFilter } from '../types';
import { FRAMES } from '../utils/frameUtils';
import { PARAM_DEFS, ContinuousParamDef } from '../utils/paramDefs';
import { FILM_SIMS, filmSimToRecipe } from '../utils/defaultRecipes';
import RecipeCreateModal from './RecipeCreateModal';
// Compact bottom sheet above the tool rail:
// bottom row = the tool's chips (presets / parameters), horizontally scrollable
@@ -12,7 +15,7 @@ import { FRAMES } from '../utils/frameUtils';
// Tapping a parameter chip toggles its slider row; chips stay visible.
// Darkroom-flavoured: amber = active/edited, red = destructive, mono labels.
type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame' | 'ratio';
type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame';
// Choice-group keys that open a strip above the chip row: color chrome groups
// (wb tab) and the D.RANGE selector (iq tab).
type GroupKey = 'cx' | 'cxb' | 'dr';
@@ -24,16 +27,23 @@ interface AdjustmentPanelProps {
adjustments: ColorAdjustments;
selectedFrame: FrameId;
useGeotag: boolean;
aspectRatio: AspectRatio;
rawEnabled: boolean;
// RAW (DNG sidecar) is only meaningful while the live camera is the source.
cameraMode: boolean;
// RAW (DNG sidecar) / aspect-ratio / metering-mode now live in the gear
// Settings sheet (gear between library picker and shutter) — no longer
// tool-rail tabs.
// The parameter whose slider row is open — lifted to App so it can also drive
// drag-anywhere-on-the-image adjustment while the row is open.
openParam: string | null;
onOpenParamChange: (key: string | null) => void;
// Base filter of the currently applied recipe — seeds the CREATE modal's
// SIMULATION row so quick-input starts from the active look.
currentBaseFilter?: BaseFilter;
// CREATE quick-input saves a brand-new recipe (name + adjustments from the
// modal) and applies it, unlike onSaveRecipe which snapshots the live state.
onCreateRecipe: (recipe: Omit<Recipe, 'id' | 'isCustom'>) => void;
onSelectRecipe: (recipe: Recipe) => void;
onUpdateAdjustments: (adjustments: Partial<ColorAdjustments>) => void;
onUpdateFrame: (frameId: FrameId) => void;
onToggleGeotag: (enabled: boolean) => void;
onAspectRatio: (ratio: AspectRatio) => void;
onRawEnabled: (enabled: boolean) => void;
onSaveRecipe: (name: string) => void;
onDeleteRecipe: (id: string) => void;
}
@@ -62,8 +72,19 @@ interface ParamDef {
}
const haptic = () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const sign = (v: number) => (v > 0 ? `+${v}` : String(v));
const oneEv = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(1)} EV`;
// WB preset → engine pair. AUTO and DAYLIGHT both land on the neutral
// 5500K / 0 pair (the engine has no scene meter), so the pair alone cannot say
// which chip is active — the last tapped preset is remembered to disambiguate.
const WB_PAIRS: Record<string, { t: number; ti: number }> = {
auto: { t: 5500, ti: 0 },
daylight: { t: 5500, ti: 0 },
'daylight-3r': { t: 5500, ti: -3 },
cloudy: { t: 6500, ti: 1 },
shade: { t: 8000, ti: 2 },
tungsten: { t: 3200, ti: 0 },
fluorescent: { t: 4000, ti: 3 },
};
export default function AdjustmentPanel({
activeTab,
@@ -72,31 +93,36 @@ export default function AdjustmentPanel({
adjustments,
selectedFrame,
useGeotag,
aspectRatio,
rawEnabled,
cameraMode,
openParam,
onOpenParamChange,
currentBaseFilter,
onCreateRecipe,
onSelectRecipe,
onUpdateAdjustments,
onUpdateFrame,
onToggleGeotag,
onAspectRatio,
onRawEnabled,
onDeleteRecipe,
}: AdjustmentPanelProps) {
// The single parameter whose slider row is open — a new selection replaces
// the previous row (one row at a time, above the chips).
const [openParam, setOpenParam] = useState<string | null>(null);
// The single choice-group whose strip (OFF/WEAK/STRONG, or the DR ladder) is
// open above the chips (same one-row-at-a-time pattern as openParam).
// open above the chips (same one-row-at-a-time pattern as openParam — which
// lives in App now, since an open row also arms image-drag adjustment).
const [openGroup, setOpenGroup] = useState<GroupKey | null>(null);
// PRESETS tab only: RECIPES chip expands the user-recipe strip above the main
// row; CREATE chip opens the quick-input modal.
const [showRecipes, setShowRecipes] = useState(false);
const [createVisible, setCreateVisible] = useState(false);
// WB tab: preset last tapped while its engine pair is still in effect (AUTO
// vs DAYLIGHT share the neutral pair, so selection can't be derived).
const [wbChoice, setWbChoice] = useState<string | null>(null);
useEffect(() => {
setOpenParam(null);
setOpenGroup(null);
setShowRecipes(false);
setCreateVisible(false);
}, [activeTab]);
const toggleParam = (key: string) => {
haptic();
setOpenParam((prev) => (prev === key ? null : key));
onOpenParamChange(openParam === key ? null : key);
};
const toggleGroup = (g: GroupKey) => {
@@ -178,51 +204,49 @@ export default function AdjustmentPanel({
);
// ---- per-tab parameter definitions ---------------------------------------
// Static bounds/labels/accessors live in PARAM_DEFS (shared with App, which
// needs the same metadata to drive drag-anywhere adjustment); here each row
// is bound to the live adjustments value + an update callback.
const toRows = (defs: ContinuousParamDef[]): ParamDef[] =>
defs.map((d) => ({
key: d.key,
label: d.label,
value: d.get(a),
default: d.defaultValue,
min: d.min,
max: d.max,
step: d.step,
display: d.display,
onChange: (v: number) => onUpdateAdjustments(d.set(v)),
}));
const paramDefs: Record<TabId, ParamDef[]> = {
recipes: [],
iq: [
{ key: 'exposure', label: 'EXPOSURE', value: a.exposure, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ exposure: v }) },
{ key: 'ev', label: 'EV', value: a.exposureCompensation ?? 0, default: 0, min: -3, max: 3, step: 0.5, display: oneEv, onChange: (v) => onUpdateAdjustments({ exposureCompensation: v }) },
{ key: 'contrast', label: 'CONTRAST', value: a.contrast, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ contrast: v }) },
{ key: 'color', label: 'COLOR', value: a.saturation, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ saturation: v }) },
{ key: 'highlight', label: 'HIGHLIGHT', value: a.highlight ?? 0, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ highlight: v }) },
{ key: 'shadow', label: 'SHADOW', value: a.shadow ?? 0, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ shadow: v }) },
],
wb: [
{ key: 'temperature', label: 'COLOR TEMP', value: a.temperature, default: 5500, min: 2500, max: 10000, step: 100, display: (v) => `${v}K`, onChange: (v) => onUpdateAdjustments({ temperature: v }) },
{ key: 'tint', label: 'TINT', value: a.tint ?? 0, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ tint: v }) },
],
filters: [
{ key: 'denoise', label: 'NOISE REDUCTION', value: a.denoise, default: 0, min: 0, max: 10, step: 1, display: String, onChange: (v) => onUpdateAdjustments({ denoise: v }) },
{ key: 'clarity', label: 'CLARITY', value: a.clarity, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ clarity: v }) },
{ key: 'sharpening', label: 'SHARPENING', value: a.sharpening ?? 0, default: 0, min: 0, max: 10, step: 1, display: String, onChange: (v) => onUpdateAdjustments({ sharpening: v }) },
{ key: 'grain', label: 'MONOCHROME GRAIN', value: a.grain, default: 0, min: 0, max: 10, step: 1, display: String, onChange: (v) => onUpdateAdjustments({ grain: v }) },
],
iq: toRows(PARAM_DEFS.iq),
wb: toRows(PARAM_DEFS.wb),
filters: toRows(PARAM_DEFS.filters),
frame: [],
ratio: [],
};
const wbPresetOf = (): string => {
// Active WB chip: the remembered preset wins while the engine pair still
// matches it; otherwise derive from the pair (neutral ⇒ AUTO; any unlisted
// pair also reads as AUTO — no preset is a stronger claim there).
const wbActive = (): string => {
const t = a.temperature ?? 5500;
const ti = a.tint ?? 0;
if (t === 5500 && ti === -3) return 'daylight-3r';
if (t === 6500 && ti === 1) return 'cloudy';
if (t === 8000 && ti === 2) return 'shade';
if (t === 3200 && ti === 0) return 'tungsten';
if (t === 4000 && ti === 3) return 'fluorescent';
if (t === 5500 && ti === 0) return 'daylight';
return 'auto';
if (wbChoice) {
const p = WB_PAIRS[wbChoice];
if (p && p.t === t && p.ti === ti) return wbChoice;
}
const hit = (Object.keys(WB_PAIRS) as string[]).find((k) => {
const p = WB_PAIRS[k];
return p.t === t && p.ti === ti;
});
return hit ?? 'auto';
};
const wbApply = (val: string) => {
switch (val) {
case 'daylight': return onUpdateAdjustments({ temperature: 5500, tint: 0 });
case 'daylight-3r': return onUpdateAdjustments({ temperature: 5500, tint: -3 });
case 'cloudy': return onUpdateAdjustments({ temperature: 6500, tint: 1 });
case 'shade': return onUpdateAdjustments({ temperature: 8000, tint: 2 });
case 'tungsten': return onUpdateAdjustments({ temperature: 3200, tint: 0 });
case 'fluorescent': return onUpdateAdjustments({ temperature: 4000, tint: 3 });
default: return onUpdateAdjustments({ temperature: 5500, tint: 0 });
}
setWbChoice(val);
const p = WB_PAIRS[val] ?? WB_PAIRS.auto;
onUpdateAdjustments({ temperature: p.t, tint: p.ti });
};
// Choice-groups: one toggle chip sits in the chip row (COLOR CHROME /
@@ -270,21 +294,29 @@ export default function AdjustmentPanel({
const chipsRow = (() => {
switch (activeTab) {
case 'recipes': {
if (recipes.length === 0) {
return (
<View className="px-3 py-2">
<Text className="font-mono text-[10px] text-zinc-500">NO PRESETS - TAP SAVE ABOVE</Text>
</View>
);
}
const chips: ChipDef[] = recipes.map((r) => ({
key: r.id,
label: r.name,
active: r.id === currentRecipeId,
trash: !!r.isCustom,
onPress: () => onSelectRecipe(r),
onPressDelete: () => onDeleteRecipe(r.id),
}));
// Main row: the six standard film simulations, then the RECIPES toggle
// (expands the user/bundled recipe strip above) and CREATE (quick-input
// modal). Recipes themselves are no longer inline chips here.
const chips: ChipDef[] = [
...FILM_SIMS.map((s) => ({
key: s.id,
label: s.name,
active: currentRecipeId === s.id,
onPress: () => onSelectRecipe(filmSimToRecipe(s)),
})),
{
key: 'recipes-toggle',
label: 'RECIPES',
active: showRecipes,
onPress: () => setShowRecipes((v) => !v),
},
{
key: 'create',
label: 'CREATE',
active: false,
onPress: () => setCreateVisible(true),
},
];
return (
<ScrollView
horizontal
@@ -330,7 +362,7 @@ export default function AdjustmentPanel({
{ v: 'tungsten', d: 'TUNGSTEN' },
{ v: 'fluorescent', d: 'FLUOR' },
],
wbPresetOf(),
wbActive(),
wbApply
)}
{paramChips(paramDefs.wb)}
@@ -372,36 +404,6 @@ export default function AdjustmentPanel({
</ScrollView>
);
case 'ratio':
// Composition aspect (FULL = current fullscreen, 4:3 / 3:2 are the
// upright photo ratios) + RAW DNG sidecar toggle (camera captures
// only — dimmed in library mode where the shutter exports a still).
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
>
{renderChip({
key: 'raw',
label: rawEnabled ? 'RAW ON' : 'RAW OFF',
active: rawEnabled,
disabled: !cameraMode,
onPress: () => onRawEnabled(!rawEnabled),
})}
{choiceChips(
'ratio',
[
{ v: 'full', d: 'FULL' },
{ v: '4:3', d: '4:3' },
{ v: '3:2', d: '3:2' },
],
aspectRatio,
(v) => onAspectRatio(v as AspectRatio)
)}
</ScrollView>
);
default:
return null;
}
@@ -469,7 +471,47 @@ export default function AdjustmentPanel({
</ScrollView>
</View>
)}
{/* PRESETS: expanded recipe strip (above the main chip row) while the
RECIPES chip is open — bundled defaults + user-created, custom
deletable inline. */}
{activeTab === 'recipes' && showRecipes && (
<View className="border-b border-zinc-800/60">
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 6 }}
>
{recipes.length === 0
? renderChip({
key: 'no-recipes',
label: 'NO RECIPES YET - TAP CREATE',
disabled: true,
active: false,
onPress: () => {},
})
: recipes.map((r) =>
renderChip({
key: r.id,
label: r.name,
active: r.id === currentRecipeId,
trash: !!r.isCustom,
onPress: () => onSelectRecipe(r),
onPressDelete: () => onDeleteRecipe(r.id),
})
)}
</ScrollView>
</View>
)}
{chipsRow}
<RecipeCreateModal
visible={createVisible}
currentBase={currentBaseFilter}
onClose={() => setCreateVisible(false)}
onSave={(recipe) => {
onCreateRecipe(recipe);
setShowRecipes(true);
}}
/>
</View>
);
}
+22 -12
View File
@@ -1,13 +1,14 @@
import React from 'react';
import { View, Text, TouchableOpacity, Image, StyleSheet } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Image as ImageIcon, Eye, Download, Camera } from 'lucide-react-native';
import { Image as ImageIcon, Eye, Download, Camera, Settings } from 'lucide-react-native';
interface CameraControlsProps {
lastPhotoUri: string | null;
onCapture: () => void;
onPickImage: () => void;
onOpenPreview: () => void;
onOpenSettings: () => void;
isLibraryMode: boolean;
onSwitchToCamera?: () => void;
}
@@ -17,6 +18,7 @@ export default function CameraControls({
onCapture,
onPickImage,
onOpenPreview,
onOpenSettings,
isLibraryMode,
onSwitchToCamera,
}: CameraControlsProps) {
@@ -27,14 +29,24 @@ export default function CameraControls({
style={{ paddingBottom: insets.bottom + 10 }}
className="flex-row items-center justify-between px-7 pt-2"
>
{/* Pick Image Button (left) */}
<TouchableOpacity
onPress={onPickImage}
className="w-12 h-12 rounded-full bg-black/40 border border-zinc-700/70 flex items-center justify-center"
activeOpacity={0.7}
>
<ImageIcon size={20} color="#e4e4e7" strokeWidth={1.75} />
</TouchableOpacity>
{/* Left cluster: Pick Image + gear (gear sits between library & shutter) */}
<View className="flex-row items-center space-x-3">
<TouchableOpacity
onPress={onPickImage}
className="w-12 h-12 rounded-full bg-black/40 border border-zinc-700/70 flex items-center justify-center"
activeOpacity={0.7}
>
<ImageIcon size={20} color="#e4e4e7" strokeWidth={1.75} />
</TouchableOpacity>
<TouchableOpacity
onPress={onOpenSettings}
className="w-12 h-12 rounded-full bg-black/40 border border-zinc-700/70 flex items-center justify-center"
activeOpacity={0.7}
>
<Settings size={20} color="#e4e4e7" strokeWidth={1.75} />
</TouchableOpacity>
</View>
{/* Dynamic Center Button: Shutter or Save */}
<TouchableOpacity
@@ -60,9 +72,7 @@ export default function CameraControls({
{/* Right Action Button: Eye (preview) or Camera (switch back) */}
<TouchableOpacity
onPress={isLibraryMode && onSwitchToCamera ? onSwitchToCamera : onOpenPreview}
className={`w-12 h-12 rounded-full bg-black/40 border border-zinc-700/70 flex items-center justify-center overflow-hidden ${
isLibraryMode ? '' : ''
}`}
className="w-12 h-12 rounded-full bg-black/40 border border-zinc-700/70 flex items-center justify-center overflow-hidden"
activeOpacity={0.7}
>
{isLibraryMode ? (
+219
View File
@@ -0,0 +1,219 @@
import React, { useEffect, useRef, useState } from 'react';
import { View, Text, Modal, Image, TouchableOpacity, ScrollView, Share, Alert, useWindowDimensions } from 'react-native';
import * as Sharing from 'expo-sharing';
import { X, Share2, Download, Trash2 } from 'lucide-react-native';
import { PhotoEntry } from '../types';
// Session photo viewer: swipe left/right across every photo taken/exported in
// this session. Top shows preset name + index/total; trash deletes the photo
// being viewed (confirm modal first); share/save as before. Opened from the
// thumbnail button (camera mode) or automatically after a library export —
// NOT after a camera capture (that only fills the thumbnail).
interface PhotoViewerModalProps {
visible: boolean;
photos: PhotoEntry[];
initialIndex: number;
onClose: () => void;
// Remove photo id from the session history (App also deletes cache + gallery copy).
onDelete: (id: string) => void;
// Save the (unsaved) photo id to the gallery; App stores the resulting assetId.
onSave: (id: string) => Promise<void>;
}
export default function PhotoViewerModal({
visible,
photos,
initialIndex,
onClose,
onDelete,
onSave,
}: PhotoViewerModalProps) {
const { width } = useWindowDimensions();
const [index, setIndex] = useState(Math.max(0, Math.min(initialIndex, photos.length - 1)));
const scrollRef = useRef<ScrollView>(null);
const [saving, setSaving] = useState(false);
const wasOpen = useRef(false);
// Opening the modal (or reopening on a different photo): jump to initialIndex.
// Shrinks of the list while open (deletion) must NOT re-scroll — the strip
// shifts left by one page and the same page slot keeps showing the next photo.
useEffect(() => {
if (!visible) {
wasOpen.current = false;
return;
}
if (!wasOpen.current) {
wasOpen.current = true;
if (photos.length > 0) {
const i = Math.max(0, Math.min(initialIndex, photos.length - 1));
setIndex(i);
requestAnimationFrame(() => {
scrollRef.current?.scrollTo({ x: i * width, animated: false });
});
}
}
}, [visible, initialIndex, width, photos.length]);
// Keep the page state in bounds when the list shrinks underneath us.
useEffect(() => {
if (visible && photos.length > 0 && index >= photos.length) {
const i = photos.length - 1;
setIndex(i);
scrollRef.current?.scrollTo({ x: i * width, animated: false });
}
}, [visible, index, photos.length, width]);
const photo = photos[index] || null;
const handleShare = async () => {
if (!photo) return;
try {
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(photo.uri, {
mimeType: 'image/jpeg',
dialogTitle: `Share with CamRecipe Pro - Preset: ${photo.recipeName}`,
UTI: 'public.jpeg',
});
} else {
await Share.share({
url: photo.uri,
message: `Captured with CamRecipe Pro - Preset: ${photo.recipeName}`,
});
}
} catch (error) {
console.error('Sharing failed:', error);
}
};
const handleDeletePress = () => {
if (!photo) return;
if (photo.savedToLibrary) {
// The gallery copy exists → Android itself shows its per-item delete
// confirmation, so no app modal in front of it.
onDelete(photo.id);
return;
}
// Never reached the gallery — no system prompt will happen, keep an app
// confirm as the only guard against an accidental trash tap.
Alert.alert(
'Delete Photo',
'Delete this photo? It was never saved to your gallery.',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Delete', style: 'destructive', onPress: () => onDelete(photo.id) },
]
);
};
const handleDone = async () => {
if (!photo || photo.savedToLibrary || saving) {
onClose();
return;
}
setSaving(true);
try {
await onSave(photo.id);
onClose();
} catch (e) {
console.error('Save failed:', e);
} finally {
setSaving(false);
}
};
return (
<Modal visible={visible} animationType="fade" transparent={false} onRequestClose={onClose}>
<View className="flex-1 bg-black">
{/* Top bar: preset + index/total (left), trash + close (right) */}
<View className="pt-12 pb-2 px-4 flex-row justify-between items-center">
<View className="flex-1 pr-2">
<Text className="text-zinc-500 font-mono text-xs">
{photo ? photo.recipeName.toUpperCase() : ''}
</Text>
<Text className="text-zinc-600 font-mono text-[10px] mt-0.5">
{photos.length > 0 ? `${index + 1} / ${photos.length}` : ''}
</Text>
</View>
<View className="flex-row items-center">
{photo && (
<TouchableOpacity
onPress={handleDeletePress}
className="w-9 h-9 rounded-full bg-zinc-900 flex items-center justify-center mr-2"
activeOpacity={0.7}
>
<Trash2 size={17} color="#ef4444" />
</TouchableOpacity>
)}
<TouchableOpacity
onPress={onClose}
className="w-9 h-9 rounded-full bg-zinc-900 flex items-center justify-center"
activeOpacity={0.7}
>
<X size={18} color="#ffffff" />
</TouchableOpacity>
</View>
</View>
{/* Swipeable photo strip */}
{photos.length > 0 ? (
<ScrollView
ref={scrollRef}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
onMomentumScrollEnd={(e) => {
const i = Math.round(e.nativeEvent.contentOffset.x / width);
setIndex(Math.max(0, Math.min(i, photos.length - 1)));
}}
>
{photos.map((p) => (
<View key={p.id} style={{ width }} className="justify-center items-center">
<Image
source={{ uri: p.uri }}
style={{ width, height: '100%' }}
className=""
resizeMode="contain"
/>
</View>
))}
</ScrollView>
) : (
<View className="flex-1 items-center justify-center">
<Text className="text-zinc-400 font-mono text-sm">NO PHOTOS</Text>
</View>
)}
{/* Bottom bar */}
<View className="pb-10 pt-3 px-4">
{photo && !photo.savedToLibrary && (
<Text className="text-amber-500 font-mono text-[10px] text-center mb-2">
NOT SAVED TO GALLERY - SAVE & DONE TO KEEP
</Text>
)}
<View className="flex-row justify-around items-center px-4">
<TouchableOpacity
onPress={handleShare}
disabled={!photo}
className="flex-row items-center bg-zinc-900 border border-zinc-800 px-6 py-3 rounded-full"
activeOpacity={0.7}
>
<Share2 size={16} color="#f59e0b" />
<Text className="text-white font-mono text-xs font-bold ml-2">SHARE</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleDone}
disabled={!photo || saving}
className="flex-row items-center bg-amber-500 px-6 py-3 rounded-full"
activeOpacity={0.7}
>
<Download size={16} color="#000000" />
<Text className="text-black font-mono text-xs font-bold ml-2">
{saving ? 'SAVING...' : photo && !photo.savedToLibrary ? 'SAVE & DONE' : 'DONE'}
</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
}
-153
View File
@@ -1,153 +0,0 @@
import React, { useEffect, useState } from 'react';
import { View, Text, Modal, Image, TouchableOpacity, Share, Alert } from 'react-native';
import * as Sharing from 'expo-sharing';
import * as MediaLibrary from 'expo-media-library/legacy';
import { X, Share2, Download } from 'lucide-react-native';
interface PreviewModalProps {
visible: boolean;
onClose: () => void;
photoUri: string | null;
recipeName: string;
savedToLibrary: boolean; // whether the export was already saved to the gallery
}
export default function PreviewModal({
visible,
onClose,
photoUri,
recipeName,
savedToLibrary: initialSaved,
}: PreviewModalProps) {
const [saved, setSaved] = useState(initialSaved);
// Modal stays mounted — refresh state whenever a new export is shown.
useEffect(() => {
if (visible) setSaved(initialSaved);
}, [visible, initialSaved]);
const handleShare = async () => {
if (!photoUri) return;
try {
// expo-sharing actually shares the file; React Native's Share.share cannot
// attach local files. Fall back to Share on platforms where it's unavailable.
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(photoUri, {
mimeType: 'image/jpeg',
dialogTitle: `Share with CamRecipe Pro - Preset: ${recipeName}`,
UTI: 'public.jpeg',
});
} else {
await Share.share({
url: photoUri,
message: `Captured with CamRecipe Pro - Preset: ${recipeName}`,
});
}
} catch (error) {
console.error('Sharing failed:', error);
}
};
const handleDone = async () => {
if (!photoUri || saved) {
onClose();
return;
}
try {
const perm = await MediaLibrary.requestPermissionsAsync();
if (!perm.granted) {
Alert.alert(
'Not Saved',
'Media library access was denied, so this photo was not saved to your gallery. You can still share it from here.'
);
onClose();
return;
}
await MediaLibrary.saveToLibraryAsync(photoUri);
setSaved(true);
onClose();
} catch (error) {
console.error('Save to gallery failed:', error);
Alert.alert('Save Failed', 'Could not save this photo to your gallery.');
onClose();
}
};
const handleClose = () => {
if (photoUri && !saved) {
Alert.alert(
'Not Saved to Gallery',
'This photo lives in the app cache only. Tap DONE to save a copy to your gallery, or SHARE it now.'
);
return;
}
onClose();
};
return (
<Modal
visible={visible}
animationType="fade"
transparent={false}
onRequestClose={handleClose}
>
<View className="flex-1 bg-black justify-between py-6 px-4">
{/* Top bar */}
<View className="flex-row justify-between items-center px-2">
<Text className="text-zinc-500 font-mono text-xs">PRESET: {recipeName.toUpperCase()}</Text>
<TouchableOpacity
onPress={handleClose}
className="w-8 h-8 rounded-full bg-zinc-900 flex items-center justify-center"
activeOpacity={0.7}
>
<X size={18} color="#ffffff" />
</TouchableOpacity>
</View>
{/* Image display */}
<View className="flex-1 justify-center items-center my-4">
{photoUri ? (
<Image
source={{ uri: photoUri }}
className="w-full aspect-[3/4] max-h-[75vh] rounded-lg border border-zinc-800"
resizeMode="contain"
/>
) : (
<Text className="text-zinc-400 font-mono text-sm">NO IMAGE CAPTURED</Text>
)}
</View>
{/* Bottom bar */}
<View>
{photoUri && !saved && (
<Text className="text-amber-500 font-mono text-[10px] text-center mb-2">
NOT SAVED TO GALLERY - TAP DONE TO SAVE
</Text>
)}
<View className="flex-row justify-around items-center px-4">
<TouchableOpacity
onPress={handleShare}
disabled={!photoUri}
className="flex-row items-center space-x-2 bg-zinc-900 border border-zinc-800 px-6 py-3 rounded-full"
activeOpacity={0.7}
>
<Share2 size={16} color="#f59e0b" />
<Text className="text-white font-mono text-xs font-bold">SHARE</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleDone}
className="flex-row items-center space-x-2 bg-amber-500 px-6 py-3 rounded-full"
activeOpacity={0.7}
>
<Download size={16} color="#000000" />
<Text className="text-black font-mono text-xs font-bold">
{saved ? 'DONE' : 'SAVE & DONE'}
</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
}
+417
View File
@@ -0,0 +1,417 @@
import React, { useEffect, useState } from 'react';
import {
View,
Text,
TextInput,
ScrollView,
TouchableOpacity,
Modal,
KeyboardAvoidingView,
Platform,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as Haptics from 'expo-haptics';
import { Recipe, ColorAdjustments, BaseFilter } from '../types';
import { DEFAULT_ADJUSTMENTS, FILM_SIMS } from '../utils/defaultRecipes';
// "CREATE" quick-input form shown above the viewfinder. Numeric knobs are
// plain textboxes (typed fast, one row each), categorical knobs are chip
// dropdowns. Used to enter Fuji-style recipes (DR/Highlight/Shadow/Color/NR/
// Sharpness/Clarity/Grain/CC/CCB/WB+shift/ISO/EV) without touching sliders.
const haptic = () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
interface RecipeCreateModalProps {
visible: boolean;
// Id of the currently applied film sim (or bundled recipe whose look should
// prefill the SIMULATION row); undefined → default to PROVIA.
currentBase?: BaseFilter;
onClose: () => void;
onSave: (recipe: Omit<Recipe, 'id' | 'isCustom'>) => void;
}
// WB presets mirror the WB-tab chips but keep pure presets (no -R variants —
// those are the separate Red/Blue offset textboxes below).
const WB_PRESETS: { key: string; label: string; temperature: number; tint: number }[] = [
{ key: 'auto', label: 'AUTO', temperature: 5500, tint: 0 },
{ key: 'daylight', label: 'DAYLIGHT', temperature: 5500, tint: 0 },
{ key: 'cloudy', label: 'CLOUDY', temperature: 6500, tint: 1 },
{ key: 'shade', label: 'SHADE', temperature: 8000, tint: 2 },
{ key: 'tungsten', label: 'TUNGSTEN', temperature: 3200, tint: 0 },
{ key: 'fluor', label: 'FLUOR', temperature: 4000, tint: 3 },
];
const WB_KEYS = WB_PRESETS.map((w) => w.key);
const DR_OPTS = [
{ v: 'auto', d: 'AUTO' },
{ v: '100', d: 'DR100' },
{ v: '200', d: 'DR200' },
{ v: '400', d: 'DR400' },
] as const;
const CC_OPTS = [
{ v: 'none', d: 'OFF' },
{ v: 'weak', d: 'WEAK' },
{ v: 'strong', d: 'STRONG' },
] as const;
const GRAIN_OPTS = [
{ v: '0', d: 'OFF' },
{ v: '3', d: 'WEAK' },
{ v: '6', d: 'STRONG' },
] as const;
// ISO has no manual control path on this build (the vision-camera lib only
// exposes exposure locking on iOS; Android CameraX keeps ISO automatic), so the
// modal shows AUTO. If a native path ever appears, feed isoSupported=true and
// the row below turns into a selectable chip list.
const ISO_SUPPORTED = false;
const ISO_STOPS = [200, 400, 800, 1600, 3200, 6400];
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
const int = (s: string, fallback: number, lo: number, hi: number) => {
const n = parseInt(s, 10);
return Number.isFinite(n) ? clamp(n, lo, hi) : fallback;
};
interface ChipProps {
label: string;
active: boolean;
onPress: () => void;
disabled?: boolean;
}
const Chip = ({ label, active, onPress, disabled }: ChipProps) => (
<TouchableOpacity
onPress={() => { if (!disabled) { haptic(); onPress(); } }}
className={`mx-0.5 rounded-full border px-2.5 py-1 ${
disabled
? 'border-zinc-800 bg-black/30'
: active
? 'border-amber-500/70 bg-amber-500/15'
: 'border-zinc-700/60 bg-titan/80'
}`}
activeOpacity={0.7}
>
<Text
className={`font-mono text-[10px] font-bold tracking-wide ${
disabled ? 'text-zinc-600' : active ? 'text-amber-500' : 'text-zinc-300'
}`}
>
{label}
</Text>
</TouchableOpacity>
);
const ChipRow = ({ children }: { children: React.ReactNode }) => (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingVertical: 2 }}
>
{children}
</ScrollView>
);
const LABEL_W = 132;
interface NumRowProps {
label: string;
value: string;
onChange: (s: string) => void;
placeholder?: string;
width?: number;
}
const NumRow = ({ label, value, onChange, placeholder, width = 58 }: NumRowProps) => (
<View style={{ width: '48%' }} className="mb-1.5">
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mb-0.5">
{label}
</Text>
<TextInput
value={value}
onChangeText={onChange}
keyboardType="numbers-and-punctuation"
placeholder={placeholder ?? '0'}
placeholderTextColor="#52525b"
className="bg-carbon border border-zinc-800 rounded-md px-2 py-1 text-white font-mono text-sm text-right"
style={{ width }}
/>
</View>
);
export default function RecipeCreateModal({
visible,
currentBase,
onClose,
onSave,
}: RecipeCreateModalProps) {
const insets = useSafeAreaInsets();
// Seed from the current film sim (or PROVIA) so "other params" start at the
// sim's standard values and the user only types what they want to change.
const seedBase: BaseFilter =
currentBase && FILM_SIMS.some((s) => s.baseFilter === currentBase) ? currentBase : 'provia';
const seedAdj = FILM_SIMS.find((s) => s.baseFilter === seedBase)?.adjustments ?? DEFAULT_ADJUSTMENTS;
// Every sim's standard values as form state — used to seed the modal and to
// reload the form when the SIMULATION chip changes.
const valuesOf = (b: BaseFilter) => {
const s = FILM_SIMS.find((x) => x.baseFilter === b)?.adjustments ?? DEFAULT_ADJUSTMENTS;
return {
dr: String(s.dynamicRange ?? 'auto'),
grain: String(s.grain === 0 ? 0 : s.grain >= 6 ? 6 : 3),
cc: s.colorChrome ?? 'none',
ccb: s.colorChromeBlue ?? 'none',
wbKey: WB_KEYS.find((k) => {
const p = WB_PRESETS.find((w) => w.key === k)!;
return p.temperature === s.temperature && p.tint === s.tint;
}) ?? 'auto',
highlight: String(s.highlight),
shadow: String(s.shadow),
color: String(s.saturation),
nr: String(s.denoise),
sharp: String(s.sharpening),
clarity: String(s.clarity),
ev: String(s.exposureCompensation),
};
};
const [sim, setSim] = useState<BaseFilter>(seedBase);
const [name, setName] = useState('');
const [dr, setDr] = useState<string>(seedAdj.dynamicRange == null ? 'auto' : String(seedAdj.dynamicRange));
const [grain, setGrain] = useState<string>(String(seedAdj.grain === 0 ? 0 : seedAdj.grain >= 6 ? 6 : 3));
const [cc, setCc] = useState<string>(seedAdj.colorChrome ?? 'none');
const [ccb, setCcb] = useState<string>(seedAdj.colorChromeBlue ?? 'none');
const [wbKey, setWbKey] = useState<string>(
WB_KEYS.find((k) => {
const p = WB_PRESETS.find((w) => w.key === k)!;
return p.temperature === seedAdj.temperature && p.tint === seedAdj.tint;
}) ?? 'auto'
);
const [wbRed, setWbRed] = useState('0');
const [wbBlue, setWbBlue] = useState('0');
const [fHigh, setFHigh] = useState(String(seedAdj.highlight));
const [fShadow, setFShadow] = useState(String(seedAdj.shadow));
const [fColor, setFColor] = useState(String(seedAdj.saturation));
const [fNr, setFNr] = useState(String(seedAdj.denoise));
const [fSharp, setFSharp] = useState(String(seedAdj.sharpening));
const [fClarity, setFClarity] = useState(String(seedAdj.clarity));
const [fEv, setFEv] = useState(String(seedAdj.exposureCompensation));
const [iso, setIso] = useState<number | null>(null);
// Re-seed whenever the modal opens (or the applied look changes) so CREATE
// quick-input always starts from the current simulation.
useEffect(() => {
if (!visible) return;
const v = valuesOf(seedBase);
setSim(seedBase);
setDr(v.dr);
setGrain(v.grain);
setCc(v.cc);
setCcb(v.ccb);
setWbKey(v.wbKey);
setWbRed('0');
setWbBlue('0');
setFHigh(v.highlight);
setFShadow(v.shadow);
setFColor(v.color);
setFNr(v.nr);
setFSharp(v.sharp);
setFClarity(v.clarity);
setFEv(v.ev);
setName('');
setIso(null);
}, [visible, currentBase]);
// Switching the simulation reloads its standard values into the form.
const selectSim = (b: BaseFilter) => {
setSim(b);
const v = valuesOf(b);
setDr(v.dr);
setGrain(v.grain);
setCc(v.cc);
setCcb(v.ccb);
setWbKey(v.wbKey);
setFHigh(v.highlight);
setFShadow(v.shadow);
setFColor(v.color);
setFNr(v.nr);
setFSharp(v.sharp);
setFClarity(v.clarity);
setFEv(v.ev);
};
const handleSave = () => {
const wb = WB_PRESETS.find((w) => w.key === wbKey) ?? WB_PRESETS[0];
const simAdj = FILM_SIMS.find((s) => s.baseFilter === sim)?.adjustments ?? DEFAULT_ADJUSTMENTS;
const adj: ColorAdjustments = {
...DEFAULT_ADJUSTMENTS,
...simAdj,
temperature: wb.temperature,
tint: wb.tint,
wbRed: int(wbRed, 0, -9, 9),
wbBlue: int(wbBlue, 0, -9, 9),
highlight: int(fHigh, simAdj.highlight, -10, 10),
shadow: int(fShadow, simAdj.shadow, -10, 10),
saturation: int(fColor, simAdj.saturation, -10, 10),
denoise: int(fNr, simAdj.denoise, 0, 10),
sharpening: int(fSharp, simAdj.sharpening, 0, 10),
clarity: int(fClarity, simAdj.clarity, -10, 10),
grain: parseInt(grain, 10),
colorChrome: cc as ColorAdjustments['colorChrome'],
colorChromeBlue: ccb as ColorAdjustments['colorChromeBlue'],
dynamicRange: (dr === 'auto' ? 'auto' : parseInt(dr, 10)) as ColorAdjustments['dynamicRange'],
exposureCompensation: int(fEv, simAdj.exposureCompensation, -3, 3),
};
onSave({
name: name.trim() || 'NEW RECIPE',
baseFilter: sim,
adjustments: adj,
frameId: 'none',
useGeotag: true,
});
setName('');
onClose();
};
const fieldSets: { label: string; value: string; set: (s: string) => void; ph?: string }[] = [
{ label: 'HIGHLIGHT', value: fHigh, set: setFHigh },
{ label: 'SHADOW', value: fShadow, set: setFShadow },
{ label: 'COLOR', value: fColor, set: setFColor },
{ label: 'NOISE RED.', value: fNr, set: setFNr, ph: '0-10' },
{ label: 'SHARPENING', value: fSharp, set: setFSharp, ph: '0-10' },
{ label: 'CLARITY', value: fClarity, set: setFClarity },
{ label: 'EXPOSURE COMP.', value: fEv, set: setFEv, ph: 'EV' },
];
return (
<Modal transparent animationType="slide" visible={visible} onRequestClose={onClose}>
<View className="absolute inset-0 bg-black/60" />
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
<View
style={{ paddingTop: insets.top + 12, paddingBottom: insets.bottom + 12, maxHeight: '100%' }}
className="bg-titan border-b border-zinc-800 rounded-b-3xl px-4 pt-1"
>
<View className="flex-row items-center justify-between mb-2">
<Text className="text-white font-mono text-sm font-bold tracking-wider">CREATE RECIPE</Text>
<TouchableOpacity onPress={onClose} className="px-2 py-1" activeOpacity={0.7}>
<Text className="text-zinc-400 font-mono text-xs font-bold">✕</Text>
</TouchableOpacity>
</View>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 8 }}>
{/* Name */}
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mb-0.5">NAME</Text>
<TextInput
value={name}
onChangeText={setName}
placeholder="Recipe name (e.g. Fuji Retro)"
placeholderTextColor="#71717a"
className="bg-carbon border border-zinc-800 rounded-lg p-2 text-white font-mono text-sm mb-2"
/>
{/* Simulation */}
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mb-0.5">SIMULATION</Text>
<ChipRow>
{FILM_SIMS.map((s) => (
<Chip key={s.id} label={s.name} active={sim === s.baseFilter} onPress={() => selectSim(s.baseFilter)} />
))}
</ChipRow>
{/* Dynamic range */}
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mt-1.5 mb-0.5">DYNAMIC RANGE</Text>
<ChipRow>
{DR_OPTS.map((o) => (
<Chip key={o.v} label={o.d} active={dr === o.v} onPress={() => setDr(o.v)} />
))}
</ChipRow>
{/* Numeric textboxes — 2 per row */}
<View className="flex-row flex-wrap justify-between mt-1.5">
{fieldSets.map((f) => (
<NumRow key={f.label} label={f.label} value={f.value} onChange={f.set} placeholder={f.ph} />
))}
</View>
{/* Grain / chrome */}
<View className="mt-1">
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mb-0.5">GRAIN EFFECT</Text>
<ChipRow>
{GRAIN_OPTS.map((o) => (
<Chip key={o.v} label={o.d} active={grain === o.v} onPress={() => setGrain(o.v)} />
))}
</ChipRow>
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mt-1.5 mb-0.5">COLOR CHROME EFFECT</Text>
<ChipRow>
{CC_OPTS.map((o) => (
<Chip key={o.v} label={o.d} active={cc === o.v} onPress={() => setCc(o.v)} />
))}
</ChipRow>
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mt-1.5 mb-0.5">COLOR CHROME EFFECT BLUE</Text>
<ChipRow>
{CC_OPTS.map((o) => (
<Chip key={o.v} label={o.d} active={ccb === o.v} onPress={() => setCcb(o.v)} />
))}
</ChipRow>
</View>
{/* White balance + R/B shift */}
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mt-1.5 mb-0.5">WHITE BALANCE</Text>
<ChipRow>
{WB_PRESETS.map((w) => (
<Chip key={w.key} label={w.label} active={wbKey === w.key} onPress={() => setWbKey(w.key)} />
))}
</ChipRow>
<View className="flex-row items-center mt-1">
<Text style={{ width: LABEL_W }} className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest">
WB SHIFT
</Text>
<Text className="text-zinc-500 font-mono text-[9px] mr-1.5">RED</Text>
<TextInput
value={wbRed}
onChangeText={setWbRed}
keyboardType="numbers-and-punctuation"
placeholder="-9..9"
placeholderTextColor="#52525b"
className="bg-carbon border border-zinc-800 rounded-md px-2 py-1 text-white font-mono text-sm text-right mr-3"
style={{ width: 52 }}
/>
<Text className="text-zinc-500 font-mono text-[9px] mr-1.5">BLUE</Text>
<TextInput
value={wbBlue}
onChangeText={setWbBlue}
keyboardType="numbers-and-punctuation"
placeholder="-9..9"
placeholderTextColor="#52525b"
className="bg-carbon border border-zinc-800 rounded-md px-2 py-1 text-white font-mono text-sm text-right"
style={{ width: 52 }}
/>
</View>
{/* ISO */}
<View className="flex-row items-center mt-1.5 mb-1">
<Text style={{ width: LABEL_W }} className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest">
ISO
</Text>
{ISO_SUPPORTED ? (
<ChipRow>
<Chip label="AUTO" active={iso === null} onPress={() => setIso(null)} />
{ISO_STOPS.map((s) => (
<Chip key={s} label={String(s)} active={iso === s} onPress={() => setIso(s)} />
))}
</ChipRow>
) : (
<Chip label="AUTO (camera default)" active disabled onPress={() => {}} />
)}
</View>
</ScrollView>
<View className="flex-row space-x-3 mt-2">
<TouchableOpacity onPress={onClose} className="flex-1 bg-zinc-800 py-3 rounded-lg items-center" activeOpacity={0.7}>
<Text className="text-zinc-300 font-mono text-xs font-semibold">CANCEL</Text>
</TouchableOpacity>
<TouchableOpacity onPress={handleSave} className="flex-1 bg-amber-500 py-3 rounded-lg items-center" activeOpacity={0.7}>
<Text className="text-black font-mono text-xs font-bold">SAVE RECIPE</Text>
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
);
}
+176
View File
@@ -0,0 +1,176 @@
import React from 'react';
import { View, Text, Modal, TouchableOpacity } from 'react-native';
import * as Haptics from 'expo-haptics';
import { X, Volume2, Settings as SettingsIcon } from 'lucide-react-native';
import { AspectRatio, MeterMode, ShutterSound } from '../types';
// Gear-sheet (gear button between library picker and shutter). Groups the
// capture/composition choices that are NOT look-adjustments, so the tool rail
// keeps only the image tools: shutter sound, composition ratio + RAW DNG
// sidecar, and full-frame metering mode (ex-FOCUS tab → METERING MODE).
interface Chip {
key: string;
label: string;
active: boolean;
onPress: () => void;
disabled?: boolean; // informational chip, not tappable (CAMERA ONLY etc.)
}
const haptic = () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
interface SettingsModalProps {
visible: boolean;
onClose: () => void;
aspectRatio: AspectRatio;
onAspectRatio: (ratio: AspectRatio) => void;
rawEnabled: boolean;
onRawEnabled: (enabled: boolean) => void;
cameraMode: boolean;
meterMode: MeterMode;
onMeterModeChange: (mode: MeterMode) => void;
shutterSound: ShutterSound;
onShutterSound: (sound: ShutterSound) => void;
}
function ChipRow({ chips }: { chips: Chip[] }) {
return (
<View className="flex-row flex-wrap items-center">
{chips.map((c) =>
c.disabled ? (
<View
key={c.key}
className="mx-1 flex-row items-center rounded-full border border-zinc-800/80 bg-black/30 px-3 py-1.5"
>
<Text className="font-mono text-[11px] font-semibold text-zinc-500">{c.label}</Text>
</View>
) : (
<TouchableOpacity
key={c.key}
onPress={() => { haptic(); c.onPress(); }}
activeOpacity={0.7}
className={`mx-1 flex-row items-center rounded-full border px-3 py-1.5 ${
c.active ? 'border-amber-500/70 bg-amber-500/15' : 'border-zinc-700/60 bg-titan/80'
}`}
>
<Text className={`font-mono text-[11px] font-bold tracking-wide ${c.active ? 'text-amber-500' : 'text-zinc-300'}`}>
{c.label}
</Text>
</TouchableOpacity>
)
)}
</View>
);
}
function SectionLabel({ children }: { children: string }) {
return (
<Text className="text-zinc-500 font-mono text-[10px] tracking-[0.2em] mb-2">{children}</Text>
);
}
function CameraOnlyNote({ cameraMode }: { cameraMode: boolean }) {
if (cameraMode) return null;
return <Text className="text-zinc-600 font-mono text-[10px] mt-1.5 ml-1">CAMERA ONLY</Text>;
}
export default function SettingsModal({
visible,
onClose,
aspectRatio,
onAspectRatio,
rawEnabled,
onRawEnabled,
cameraMode,
meterMode,
onMeterModeChange,
shutterSound,
onShutterSound,
}: SettingsModalProps) {
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View className="flex-1 bg-black/70 items-center justify-center px-6">
<TouchableOpacity
className="absolute inset-0"
activeOpacity={1}
onPress={onClose}
accessibilityLabel="Close settings"
/>
<View
className="w-full max-w-md rounded-2xl border border-zinc-800/70 p-5 pb-6 space-y-5"
style={{ backgroundColor: 'rgba(8,8,10,0.97)' }}
>
<View className="flex-row items-center justify-between px-1">
<View className="flex-row items-center">
<SettingsIcon size={16} color="#a1a1aa" strokeWidth={1.75} />
<Text className="text-zinc-400 font-mono text-xs font-bold tracking-widest ml-2">
SETTINGS
</Text>
</View>
<TouchableOpacity
onPress={onClose}
className="w-8 h-8 rounded-full bg-zinc-900 flex items-center justify-center"
activeOpacity={0.7}
>
<X size={16} color="#ffffff" />
</TouchableOpacity>
</View>
{/* Shutter sound */}
<View>
<SectionLabel>SHUTTER SOUND</SectionLabel>
<ChipRow
chips={[
{ key: 'snd-on', label: 'ON', active: shutterSound === 'on', onPress: () => onShutterSound('on') },
{ key: 'snd-off', label: 'OFF', active: shutterSound === 'off', onPress: () => onShutterSound('off') },
]}
/>
<View className="flex-row items-center mt-1.5 ml-1">
<Volume2 size={11} color="#52525b" strokeWidth={1.75} />
<Text className="text-zinc-600 font-mono text-[10px] ml-1.5">
Android has no click-volume control — OFF only. Some regions force the sound on by OS policy.
</Text>
</View>
</View>
{/* Composition ratio + RAW */}
<View>
<SectionLabel>PHOTO RATIO</SectionLabel>
<ChipRow
chips={[
{ key: 'ratio-full', label: 'FULL', active: aspectRatio === 'full', onPress: () => onAspectRatio('full') },
{ key: 'ratio-4:3', label: '4:3', active: aspectRatio === '4:3', onPress: () => onAspectRatio('4:3') },
{ key: 'ratio-3:2', label: '3:2', active: aspectRatio === '3:2', onPress: () => onAspectRatio('3:2') },
]}
/>
<View className="mt-2">
<ChipRow
chips={[
cameraMode
? { key: 'raw', label: rawEnabled ? 'RAW DNG ON' : 'RAW DNG OFF', active: rawEnabled, onPress: () => onRawEnabled(!rawEnabled) }
: { key: 'raw', label: rawEnabled ? 'RAW DNG ON' : 'RAW DNG OFF', active: rawEnabled, disabled: true, onPress: () => {} },
]}
/>
<CameraOnlyNote cameraMode={cameraMode} />
</View>
</View>
{/* Metering mode */}
<View>
<SectionLabel>METERING MODE</SectionLabel>
<ChipRow
chips={[
cameraMode
? { key: 'meter-eval', label: 'EVALUATIVE', active: meterMode === 'evaluative', onPress: () => onMeterModeChange('evaluative') }
: { key: 'meter-eval', label: 'EVALUATIVE', active: meterMode === 'evaluative', disabled: true, onPress: () => {} },
cameraMode
? { key: 'meter-hl', label: 'HIGHLIGHT-WEIGHTED', active: meterMode === 'highlight', onPress: () => onMeterModeChange('highlight') }
: { key: 'meter-hl', label: 'HIGHLIGHT-WEIGHTED', active: meterMode === 'highlight', disabled: true, onPress: () => {} },
]}
/>
<CameraOnlyNote cameraMode={cameraMode} />
</View>
</View>
</View>
</Modal>
);
}
+2 -3
View File
@@ -1,9 +1,9 @@
import React from 'react';
import { View, Text, TouchableOpacity, ScrollView } from 'react-native';
import { LayoutGrid, Sun, Thermometer, Sparkles, Frame, Ratio, type LucideIcon } from 'lucide-react-native';
import { LayoutGrid, Sun, Thermometer, Sparkles, Frame, type LucideIcon } from 'lucide-react-native';
import * as Haptics from 'expo-haptics';
export type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame' | 'ratio';
export type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame';
interface ToolRailProps {
activeTab: TabId | null;
@@ -16,7 +16,6 @@ const TOOLS: { id: TabId; label: string; icon: LucideIcon }[] = [
{ id: 'wb', label: 'WB', icon: Thermometer },
{ id: 'filters', label: 'FX', icon: Sparkles },
{ id: 'frame', label: 'FRAME', icon: Frame },
{ id: 'ratio', label: 'RATIO', icon: Ratio },
];
export default function ToolRail({ activeTab, onToggle }: ToolRailProps) {
+177 -73
View File
@@ -23,7 +23,7 @@ import {
MipmapMode,
} from '@shopify/react-native-skia';
import { Lock } from 'lucide-react-native';
import { Recipe, GPSInfo, FrameId, AspectRatio, ASPECT_RATIO_W_H } from '../types';
import { Recipe, GPSInfo, FrameId, AspectRatio, ColorAdjustments, ASPECT_RATIO_W_H } from '../types';
import { getSkiaColorMatrix, applyExposureGain } from '../utils/colorUtils';
import { TONE_SKSL, getToneUniforms } from '../utils/toneShader';
import { CINEMA_SKSL, getCinemaUniforms, cinemaOffFlat } from '../utils/cinemaShader';
@@ -52,6 +52,31 @@ interface ViewfinderProps {
onSessionError?: (error: Error) => void;
// Quick exposure slider (shown while AE/AF is locked) — live AE bias in EV.
onExposureChange?: (ev: number) => void;
// Metering-mode EV offset (highlight-weighted): extra EV stops ADDED on top
// of the recipe EV. Applied to the preview matrix AND the capture-time AE
// flush so preview/export/capture agree; the EV slider UI keeps showing the
// raw user EV. Negative = protect highlights. 0 = off.
meteringEvOffset?: number;
// Metering-mode tonal look (highlight-weighted): small extra adjustments
// merged on top of the recipe for RENDERING only (preview matrix + tone, and
// App mirrors it into the export). Real highlight-weighted metering (GR /
// Sony) doesn't only underexpose — it meters for the BRIGHTEST area to keep
// its detail (absolute no-blowout): highlight roll, deep shadow crush, raised
// contrast/clarity. Never contains exposureCompensation (that rides the EV
// offset above); UI sliders keep showing the raw recipe.
meteringAdjustments?: Partial<ColorAdjustments>;
// Continuous panel parameter whose slider row is open (App lifts it from the
// AdjustmentPanel). While set, a vertical drag ANYWHERE on the live image
// adjusts this parameter (up = increase) — no need to grab the small slider.
// Null while no param row is open; then a locked-AE drag adjusts EV instead.
imageAdjustTarget?: {
key: string;
min: number;
max: number;
step: number;
value: number;
onChange: (v: number) => void;
} | null;
}
export interface ViewfinderHandle {
@@ -81,7 +106,7 @@ const toneEffect = (() => {
try {
return Skia.RuntimeEffect.Make(TONE_SKSL);
} catch (e) {
console.error('[camtone] TONE_SKSL compile failed: ' + e);
console.error('TONE_SKSL compile failed: ' + e);
return null;
}
})();
@@ -90,7 +115,7 @@ const cinemaEffect = (() => {
try {
return Skia.RuntimeEffect.Make(CINEMA_SKSL);
} catch (e) {
console.error('[cinema] CINEMA_SKSL compile failed: ' + e);
console.error('CINEMA_SKSL compile failed: ' + e);
return null;
}
})();
@@ -118,6 +143,9 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
rawOutput,
onSessionError,
onExposureChange,
meteringEvOffset = 0,
meteringAdjustments,
imageAdjustTarget,
}: ViewfinderProps,
ref
) {
@@ -212,14 +240,20 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
Math.round(vw * 0.032)
);
const adjustments = recipe.adjustments;
const adjustments = meteringAdjustments
? { ...recipe.adjustments, ...meteringAdjustments }
: recipe.adjustments;
// Exposure compensation, applied as software 2^EV matrix gain for BOTH modes.
// Camera AE bias is only flushed to hardware at capture time
// (flushExposureBias): setting it live per slider value re-locks AE on the
// Xiaomi and flashes the preview (matrix change is instant and flicker-free).
// Metering offset (highlight-weighted) rides on top of the user EV so the
// preview shows the protected-exposure look; EV_RANGE ±3 caps the software
// gain (flush clamps to the device range separately).
const evApplied = Math.max(-3, Math.min(3, (adjustments.exposureCompensation ?? 0) + meteringEvOffset));
const colorMatrix = applyExposureGain(
getSkiaColorMatrix(recipe.baseFilter, adjustments),
adjustments.exposureCompensation ?? 0
evApplied
);
// The Skia onFrame worklet runs on a separate runtime — keep the latest color
@@ -259,6 +293,15 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
// flicker-free (preview EV is software matrix gain).
const skiaCameraRef = useRef<SkiaCameraRef>(null);
const [cameraActive, setCameraActive] = useState(false);
// Stable identities so React.memo(SkiaCameraImpl) does not re-render (and
// re-run its frame-output setup) on every adjustments change during slider
// drags — inline lambdas here used to break memo and stall the main thread.
const handleCameraStarted = useCallback(() => {
setCameraActive(true);
}, []);
const handleCameraStopped = useCallback(() => {
setCameraActive(false);
}, []);
// Camera session pauses when the app backgrounds; toggling isActive back on
// resume restarts it. Without this the preview stayed black after unlock.
const [appActive, setAppActive] = useState(AppState.currentState === 'active');
@@ -275,15 +318,16 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
// per intermediate value — each one cancelled by the next, spamming [ev]).
const evStopsRef = useRef(evStops);
evStopsRef.current = evStops;
// Metering offset mirrored into a ref: flushExposureBias is a stable
// imperative handle (useImperativeHandle deps [ref]) and must read the LIVE
// offset when the user switches metering mode.
const meteringEvOffsetRef = useRef(meteringEvOffset);
meteringEvOffsetRef.current = meteringEvOffset;
useEffect(() => {
if (!controller) return;
const d = controller.device;
if (d && d.supportsExposureBias) {
console.error(
`[ev] device range=${d.minExposureBias}..${d.maxExposureBias} current=${controller.exposureBias}`
);
} else {
console.error(`[ev] device has no exposure bias support`);
if (d && !d.supportsExposureBias) {
console.warn('Device has no exposure-bias support — EV applies to preview and export only.');
}
}, [controller]);
// Hardware AE bias is applied ONLY at capture time via flushExposureBias()
@@ -300,17 +344,16 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
const ctl = skiaCameraRef.current?.controller;
const d = ctl?.device;
if (!ctl || !d || !d.supportsExposureBias) return;
const ev = evStopsRef.current;
const ev = evStopsRef.current + meteringEvOffsetRef.current;
const index = Math.round(ev * 8);
const clamped = Math.max(d.minExposureBias, Math.min(d.maxExposureBias, index));
if (clamped === ctl.exposureBias) return;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
await ctl.setExposureBias(clamped);
console.error(`[ev] flush ev=${ev} idx=${clamped} ok, read=${ctl.exposureBias}`);
return;
} catch (e) {
console.error(`[ev] flush ev=${ev} idx=${clamped} attempt=${attempt} failed: ${String(e).slice(0, 100)}`);
console.warn(`Exposure-bias flush failed (attempt ${attempt}): ${String(e).slice(0, 100)}`);
await new Promise((r) => setTimeout(r, 80));
}
}
@@ -330,7 +373,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
const d = cam?.controller?.device;
if (!d) return;
if (!d.supportsFocusMetering && !d.supportsExposureMetering && !d.supportsWhiteBalanceMetering) {
console.error('[focus] metering unsupported on this device');
console.warn('Focus/exposure metering unsupported on this device');
return;
}
setFocusPoint({ x, y });
@@ -339,9 +382,8 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
{ x, y },
{ responsiveness: 'snappy', adaptiveness: 'locked', autoResetAfter: null }
);
console.error(`[focus] tap=${Math.round(x)},${Math.round(y)} AE/AF/AWB locked`);
} catch (e) {
console.error('[focus] lock failed: ' + String(e).slice(0, 120));
console.warn('Focus lock failed: ' + String(e).slice(0, 120));
}
};
const handleUnlockFocus = async () => {
@@ -350,9 +392,8 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
try {
await cam.resetFocus();
setFocusPoint(null);
console.error('[focus] reset to continuous auto');
} catch (e) {
console.error('[focus] reset failed: ' + String(e).slice(0, 120));
console.warn('Focus reset failed: ' + String(e).slice(0, 120));
}
};
@@ -365,41 +406,114 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
const evPxPerStop = evTrackH / (2 * EV_RANGE);
const clampEv = (v: number) => Math.max(-EV_RANGE, Math.min(EV_RANGE, v));
const evDragRef = useRef<{ y0: number; ev0: number } | null>(null);
// Last EV value actually pushed to App. Quick-EV drags otherwise fire a full
// adjustments re-render on EVERY pointer move (30-60Hz), even while the
// finger holds a step without the value changing — that render storm stalls
// the main thread and beats the Skia preview. Only push real nấc changes.
const evLastSentRef = useRef<number | null>(null);
const evThumbY = evTrackTop + ((EV_RANGE - clampEv(evStops)) / (2 * EV_RANGE)) * evTrackH;
// Screen-absolute Y of the gesture. locationY is relative to whichever view
// the finger currently hit-tests over, so crossing the small track's edge
// mid-drag changed the origin by the track offset and snapped EV by ~3 stops
// (the "jumps to the other end" bug). pageY never moves.
const pageYOf = (e: any): number => {
const t = e?.nativeEvent?.touches?.[0];
if (t && typeof t.pageY === 'number') return t.pageY;
const p = e?.nativeEvent?.pageY;
if (typeof p === 'number') return p;
return e?.nativeEvent?.locationY ?? 0;
};
const onEvSliderGrant = (e: any) => {
evDragRef.current = { y0: e.nativeEvent.locationY, ev0: evStops };
const y0 = pageYOf(e);
evDragRef.current = { y0, ev0: evStops };
evLastSentRef.current = evStops;
};
const onEvSliderMove = (e: any) => {
const g = evDragRef.current;
if (!g || !onExposureChange) return;
const dy = g.y0 - e.nativeEvent.locationY; // drag up = brighter
const dy = g.y0 - pageYOf(e); // drag up = brighter
const ev = clampEv(g.ev0 + dy / evPxPerStop);
onExposureChange(Math.round(ev * 10) / 10);
const rounded = Math.round(ev * 10) / 10;
if (rounded === evLastSentRef.current) return; // same step: skip re-render
evLastSentRef.current = rounded;
onExposureChange(rounded);
};
const onEvSliderRelease = () => {
evDragRef.current = null;
};
// Instrumentation for device logs (user captures only logcat E level, so
// console.log/warn never show up — these are error-level on purpose).
const lastPreviewLog = useRef(0);
useEffect(() => {
const now = Date.now();
if (now - lastPreviewLog.current < 400) return;
lastPreviewLog.current = now;
console.error(
`[preview] matrix=(${colorMatrix[0].toFixed(3)},${colorMatrix[5].toFixed(3)},${colorMatrix[10].toFixed(3)}) tone=[${toneParams.join(',')}] ev=${evStops}`
);
}, [colorMatrix, toneParams, evStops]);
// ---- Drag-anywhere-on-the-image adjustment --------------------------------
// Priority: an open panel param row (imageAdjustTarget) > EV (AE locked).
// A plain tap (no movement) always falls through to tap-to-focus.
const TAP_SLOP = 14; // px of movement before a touch counts as a drag
const imageAdjustTargetRef = useRef(imageAdjustTarget);
imageAdjustTargetRef.current = imageAdjustTarget;
type ImgDragSession = {
y0: number;
kind: 'param' | 'ev' | 'focus';
v0: number;
param: { min: number; max: number; step: number; onChange: (v: number) => void };
moved: boolean;
lastSent: number | null;
};
const imgDragRef = useRef<ImgDragSession | null>(null);
const onImageDragGrant = (e: any) => {
const t = imageAdjustTargetRef.current;
let kind: ImgDragSession['kind'] = 'focus';
let v0 = 0;
let param: ImgDragSession['param'] = { min: 0, max: 0, step: 0, onChange: () => {} };
if (t) {
kind = 'param';
v0 = t.value;
param = { min: t.min, max: t.max, step: t.step, onChange: t.onChange };
} else if (focusPoint && onExposureChange) {
kind = 'ev';
v0 = evStops;
}
imgDragRef.current = { y0: pageYOf(e), kind, v0, param, moved: false, lastSent: null };
};
const onImageDragMove = (e: any) => {
const g = imgDragRef.current;
if (!g || g.kind === 'focus') return;
const dy = g.y0 - pageYOf(e); // up = positive / increase
if (!g.moved) {
if (Math.abs(dy) < TAP_SLOP) return;
g.moved = true;
}
if (g.kind === 'ev') {
if (!onExposureChange) return;
const ev = clampEv(g.v0 + dy / evPxPerStop);
const rounded = Math.round(ev * 10) / 10;
if (rounded === g.lastSent) return;
g.lastSent = rounded;
evLastSentRef.current = rounded;
onExposureChange(rounded);
return;
}
const range = g.param.max - g.param.min;
let v = g.v0 + (dy * range) / evTrackH; // same feel as the EV track
v = Math.max(g.param.min, Math.min(g.param.max, v));
if (g.param.step > 0) v = Math.round(v / g.param.step) * g.param.step;
const rounded = Math.round(v * 1000) / 1000;
if (rounded === g.lastSent) return;
g.lastSent = rounded;
g.param.onChange(rounded);
};
const onImageDragEnd = (e: any) => {
const g = imgDragRef.current;
imgDragRef.current = null;
if (!g) return;
if (!g.moved) {
// Plain tap: lock focus at the tapped point (drag never re-locks).
handleTapFocus(e.nativeEvent.locationX, e.nativeEvent.locationY);
}
};
// Camera frame renderer. Stable identity (empty-ish deps): SkiaCamera re-runs
// its frame pipeline whenever onFrame changes, which during slider drags made
// the preview flicker. All live values flow through the synchronizables.
type SkiaCameraOnFrame = NonNullable<ComponentProps<typeof SkiaCamera>['onFrame']>;
const toneFailLogged = useMemo(() => createSynchronizable<boolean>(false), []);
// DEBUG-4x3 (remove after diagnosis): log actual stream geometry at runtime.
const dbgCountSync = useMemo(() => createSynchronizable<number>(0), []);
const dbgLastSync = useMemo(() => createSynchronizable<string>(''), []);
// Composition band fed to the frame worklet: [active, viewAspect, fx, fy,
// fw, fh] where (fx,fy,fw,fh) is the photo-area rect (ratio band / polaroid
// window / wall window) normalized to the view. While active the worklet
@@ -418,10 +532,23 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
(frame, render) => {
'worklet';
render(({ canvas, frameTexture }) => {
// Quick-EV drags write colorMatrixSync from JS on every tick; a torn
// read can throw inside MakeMatrix (or yield NaN -> black surface) for
// one frame. Validate + try/catch, fall back to identity (no filter):
// a 1-frame gain error beats a 1-frame black beat. DEBUG-EV.
const paint = Skia.Paint();
paint.setColorFilter(Skia.ColorFilter.MakeMatrix(colorMatrixSync.getBlocking()));
const tone = toneSync.getBlocking();
const cinema = cinemaSync.getBlocking();
try {
const cm = colorMatrixSync.getDirty();
if (cm != null && cm.length === 20 && cm.every((v) => typeof v === 'number' && Number.isFinite(v))) {
paint.setColorFilter(Skia.ColorFilter.MakeMatrix(cm));
} else {
console.warn('Invalid color matrix (len ' + (cm == null ? 'null' : cm.length) + ') — filter skipped');
}
} catch (e) {
console.warn('Color matrix build failed: ' + e);
}
const tone = toneSync.getDirty();
const cinema = cinemaSync.getDirty();
const hasTone = toneEffect != null && (tone[0] !== 0 || tone[1] !== 0 || tone[2] !== 0);
const hasCinema = cinemaEffect != null && cinema[0] > 0;
// Draws the full frame with the tone/cinema shader when active (drawRect,
@@ -450,7 +577,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
} catch (e) {
if (!toneFailLogged.getBlocking()) {
toneFailLogged.setBlocking(true);
console.error('[camtone] shader failed, matrix-only: ' + e);
console.error('Tone shader failed, matrix-only: ' + e);
}
}
}
@@ -463,21 +590,12 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
// (under the renderToTexture rotation/mirror CTM) and cover-map the
// sensor there. Equal-aspect bands (ratio 4:3 == native 3:4 sensor)
// then show the ENTIRE sensor; narrower bands crop like the export.
const feed = feedRectSync.getBlocking();
const feed = feedRectSync.getDirty();
const ftW = frameTexture.width();
const ftH = frameTexture.height();
const orient = frame.orientation;
const dbgMir = frame.isMirrored === true;
const dbgCount = dbgCountSync.getBlocking();
dbgCountSync.setBlocking(dbgCount + 1);
const bandActive = feed[0] > 0;
if (!bandActive) {
// DEBUG-4x3 (remove): stream ground truth, no-band branch
const line = `ft=${ftW}x${ftH} o=${orient} mir=${dbgMir} band=0 feed=[${feed.map((f) => f.toFixed(3)).join(',')}]`;
if (dbgCount % 60 === 0 || line !== dbgLastSync.getBlocking()) {
dbgLastSync.setBlocking(line);
console.error('[camdebug] ' + line);
}
drawFullFrame();
return;
}
@@ -549,12 +667,6 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
const dh = s * ftH;
const dx = u0 + (rw - dw) / 2;
const dy = v0 + (rh - dh) / 2;
// DEBUG-4x3 (remove): full geometry log for the band branch
const line = `ft=${ftW}x${ftH} o=${orient} mir=${dbgMir} band=1 feed=[${feed.map((f) => f.toFixed(3)).join(',')}] up=${ow}x${oh} ac=${ac.toFixed(4)} vis=${visX.toFixed(3)}x${visY.toFixed(3)} X0=${X0.toFixed(0)} Y0=${Y0.toFixed(0)} X1=${X1.toFixed(0)} Y1=${Y1.toFixed(0)} raw=${u0.toFixed(0)},${v0.toFixed(0)} ${rw.toFixed(0)}x${rh.toFixed(0)} s=${s.toFixed(4)} d=${dx.toFixed(0)},${dy.toFixed(0)}`;
if (dbgCount % 60 === 0 || line !== dbgLastSync.getBlocking()) {
dbgLastSync.setBlocking(line);
console.error('[camdebug] ' + line);
}
// Fresh paint; its default color is opaque black (Skia default), so
// the surface fill below needs no explicit color set.
const blackPaint = Skia.Paint();
@@ -644,19 +756,6 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
: [0, 0, 0, 0, 0, 0]
);
}, [cameraPhotoRect, vw, vh, feedRectSync]);
// DEBUG-4x3 (remove): correlate JS view/region state with the worklet log.
useEffect(() => {
console.error(
'[camdebug-js] region=' + JSON.stringify(region) +
' band=' + regionBand +
' ratio=' + aspectRatio +
' frame=' + selectedFrame +
' view=' + vw + 'x' + vh +
' feed=' + (cameraPhotoRect ? JSON.stringify([1, vw / vh, cameraPhotoRect.x / vw, cameraPhotoRect.y / vh, cameraPhotoRect.w / vw, cameraPhotoRect.h / vh]) : 'null') +
' frameTarget=' + (frameTarget ? frameTarget.width + 'x' + frameTarget.height : 'null')
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [region, regionBand, aspectRatio, selectedFrame, cameraPhotoRect]);
const onLibTouchStart = (e: any) => {
const ts = e.nativeEvent.touches;
if (ts.length >= 2) {
@@ -981,16 +1080,21 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
outputs={cameraOutputs}
pixelFormat="yuv"
targetResolution={frameTarget}
onStarted={() => setCameraActive(true)}
onStarted={handleCameraStarted}
onStopped={handleCameraStopped}
onFrame={handleFrame}
onError={onSessionError}
/>
{/* Tap-to-focus layer: fullscreen responder (all visual overlays above
are pointerEvents none). locationX/Y are relative to this view. */}
{/* Gesture layer: tap = AE/AF lock; vertical drag on the image adjusts
the open panel parameter (or EV while AE is locked). Sits below the
quick-EV strip, which keeps its own narrower direct-drag handle. */}
<View
className="absolute inset-0"
onStartShouldSetResponder={() => true}
onResponderGrant={(e) => handleTapFocus(e.nativeEvent.locationX, e.nativeEvent.locationY)}
onResponderGrant={onImageDragGrant}
onResponderMove={onImageDragMove}
onResponderRelease={onImageDragEnd}
onResponderTerminate={onImageDragEnd}
/>
{focusPoint && (
<View
+23 -1
View File
@@ -5,6 +5,24 @@ export type FrameId = 'none' | 'polaroid' | 'classic-white' | 'cinematic' | 'wal
// 2:3 photo (w/h = 2/3). "full" = current fullscreen behaviour (no ratio crop).
// Wall Frame ignores the ratio (frame + export keep fullscreen as before).
export type AspectRatio = 'full' | '4:3' | '3:2';
// Full-frame metering behaviour. Evaluative = the camera's default whole-scene
// metering (offset 0). Highlight-weighted protects bright areas by underexposing
// (~ -0.7EV) — the camera lib exposes no real metering-mode API, so this is the
// closest honest approximation via the existing EV path.
export type MeterMode = 'evaluative' | 'highlight';
// Camera shutter sound. OS policy may force the click on in some regions;
// there is no native volume control (OFF/ON only).
export type ShutterSound = 'on' | 'off';
// One photo in the in-app session history (recent captures + library exports).
// uri = app-cache JPEG; assetId (when savedToLibrary) = the MediaLibrary asset
// so the trash button can remove the gallery copy too.
export interface PhotoEntry {
id: string;
uri: string;
recipeName: string;
savedToLibrary: boolean;
assetId?: string | null;
}
// w/h of the final upright photo for each non-full choice.
export const ASPECT_RATIO_W_H: Record<Exclude<AspectRatio, 'full'>, number> = {
'4:3': 3 / 4,
@@ -16,12 +34,16 @@ export const ASPECT_RATIO_W_H: Record<Exclude<AspectRatio, 'full'>, number> = {
// cool tint in shade, plus a seasonal atmospheric haze/fog veil.
export type CinemaSeason = 'spring' | 'summer' | 'autumn' | 'winter';
export type BaseFilter = 'classic-neg' | 'classic-chrome' | 'provia' | 'velvia' | 'astia' | 'eterna' | 'monochrome' | 'none';
export interface ColorAdjustments {
exposure: number; // -10 to +10 (mapped to matrix multiplier or offset)
contrast: number; // -10 to +10
saturation: number; // -10 to +10
temperature: number; // 2500 to 10000 (Kelvin)
tint: number; // -10 to +10
wbRed?: number; // Fuji WB fine-tune, Red axis (-9..9) — extra red-channel gain
wbBlue?: number; // Fuji WB fine-tune, Blue axis (-9..9) — extra blue-channel gain
highlight: number; // -10 to +10 (- pulls highlights down / roll, + brightens)
shadow: number; // -10 to +10 (+ lifts shadows, - deepens)
denoise: number; // 0 to 10 (mapped to blur sigma)
@@ -38,7 +60,7 @@ export interface Recipe {
id: string;
name: string;
isCustom?: boolean;
baseFilter: 'classic-neg' | 'provia' | 'velvia' | 'monochrome' | 'none';
baseFilter: BaseFilter;
adjustments: ColorAdjustments;
frameId?: FrameId; // e.g. 'none', 'polaroid', 'classic-white', 'cinematic', 'wallframe'
cinema?: CinemaSeason | null; // seasonal atmosphere grade (optional)
+42 -3
View File
@@ -1,6 +1,4 @@
import { ColorAdjustments } from '../types';
export type BaseFilter = 'classic-neg' | 'provia' | 'velvia' | 'monochrome' | 'none';
import { ColorAdjustments, BaseFilter } from '../types';
// Kelvin to RGB compensation for White Balance.
// In photography, when WB is set HIGH (e.g. 10000K), the camera understands
@@ -75,6 +73,31 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
-0.05, -0.05, 1.25, 0, -0.02,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'classic-chrome') {
// Classic Chrome: muted/deep color with strong tone — desaturated reds and
// greens, slight warm amber in highlights, shadows stay soft (approx).
matrix = [
1.12, -0.03, -0.06, 0, 0.04,
-0.04, 1.0, -0.03, 0, 0.03,
-0.05, -0.06, 0.86, 0, -0.02,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'astia') {
// Astia: soft, gentle, flattering — low contrast, pastel-leaning colors.
matrix = [
1.0, 0.02, 0.02, 0, 0.01,
0.01, 1.0, 0.02, 0, 0.02,
0.01, 0.01, 1.02, 0, 0.02,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'eterna') {
// Eterna: cinema — flat (low contrast), muted color, gentle highlight rolloff.
matrix = [
0.94, 0.02, 0.02, 0, 0.02,
0.02, 0.96, 0.02, 0, 0.02,
0.02, 0.02, 0.94, 0, 0.02,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'monochrome') {
// Acros Monochrome: ITU-R BT.709 luma — pure grayscale, zero color leakage
const r = 0.2126;
@@ -148,6 +171,22 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
matrix = multiplyMatrices(wbMat, matrix);
}
// 5b. Fuji WB fine-tune Red/Blue axis (the CREATE modal's R/B offsets). These
// axes are independent channel gains on top of the Kelvin/tint preset: each
// step nudges its channel ~1.1% toward/away from neutral, matching the
// perceptible size of one Fuji fine-tune step (-9..+9).
const wbRed = adj.wbRed ?? 0;
const wbBlue = adj.wbBlue ?? 0;
if (baseFilter !== 'monochrome' && (wbRed !== 0 || wbBlue !== 0)) {
const shiftMat = [
1 + (wbRed / 9) * 0.1, 0, 0, 0, 0,
0, 1, 0, 0, 0,
0, 0, 1 + (wbBlue / 9) * 0.1, 0, 0,
0, 0, 0, 1, 0,
];
matrix = multiplyMatrices(shiftMat, matrix);
}
// 6. Exposure & Contrast — applied to ALL base filters (monochrome included) so
// both keep affecting mono shots. Contrast scales around mid-grey 0.5.
if (exposure !== 0 || contrast !== 0) {
+36
View File
@@ -9,6 +9,8 @@ export const DEFAULT_ADJUSTMENTS: ColorAdjustments = {
saturation: 0,
temperature: 5500,
tint: 0,
wbRed: 0,
wbBlue: 0,
highlight: 0,
shadow: 0,
denoise: 0,
@@ -21,6 +23,40 @@ export const DEFAULT_ADJUSTMENTS: ColorAdjustments = {
exposureCompensation: 0,
};
// Standard Fuji film simulations. These are the fixed "look" presets shown at
// the front of the PRESETS chip row: tapping one applies the simulation via
// its baseFilter colour matrix ONLY — every other knob resets to its neutral
// default (0 / auto) so the sim is a clean starting point, never a baked
// recipe. Any look beyond the matrix (grain, DR, CC, contrast…) is added by
// the user afterwards.
export interface FilmSim {
id: string; // Recipe-like id used only while the sim is the current selection
name: string;
baseFilter: Recipe['baseFilter'];
adjustments: ColorAdjustments;
}
export const FILM_SIMS: FilmSim[] = [
{ id: 'sim-provia', name: 'PROVIA', baseFilter: 'provia', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-velvia', name: 'VELVIA', baseFilter: 'velvia', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-classic-chrome', name: 'CLASSIC CHROME', baseFilter: 'classic-chrome', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-astia', name: 'ASTIA', baseFilter: 'astia', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-eterna', name: 'ETERNA', baseFilter: 'eterna', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-acros', name: 'ACROS', baseFilter: 'monochrome', adjustments: { ...DEFAULT_ADJUSTMENTS } },
];
// Film sim → full Recipe (never persisted; sims are applied on the fly).
export function filmSimToRecipe(sim: FilmSim): Recipe {
return {
id: sim.id,
name: sim.name,
baseFilter: sim.baseFilter,
adjustments: { ...sim.adjustments },
frameId: 'none',
useGeotag: true,
};
}
export const DEFAULT_RECIPES: Recipe[] = [
{
id: 'classic-neg-default',
+16 -37
View File
@@ -13,6 +13,9 @@ import { patchJpegDpi } from './jpegDpi';
export interface ExportResult {
uri: string;
savedToLibrary: boolean;
// MediaLibrary asset id when the photo was saved to the gallery — lets the
// photo viewer's trash button remove the gallery copy too.
assetId?: string | null;
}
export interface ExportOptions {
@@ -69,9 +72,7 @@ function screenSharpenImage(image: SkImage, amount = 0.5): SkImage {
// Unique filename per export. A fixed path made every export share one URI, and
// RN's <Image> cache (keyed by URI) then kept showing the previous photo in the
// thumbnail after a new capture overwrote the file. A fresh URI per export busts
// that cache. Previous export file is deleted after the new one is written and
// gallery-saved, so at most one stale temp file can ever exist.
let lastExportPath: string | null = null;
// that cache.
const nextExportFile = () => `${FileSystem.cacheDirectory}camrecipe_pro_export_${Date.now()}.jpg`;
export async function processAndExportPhoto(
@@ -82,10 +83,6 @@ export async function processAndExportPhoto(
gpsInfo: GPSInfo | null,
options?: ExportOptions
): Promise<ExportResult | null> {
const t0 = Date.now();
// Direct console.error templates (user log captures are E-level only). Steps
// use full static prefixes — release minification dropped step literals when
// they were passed through a wrapper, so no indirection here.
try {
// 1. Read source image file into Skia
const skiaData = await Skia.Data.fromURI(sourceUri);
@@ -99,13 +96,9 @@ export async function processAndExportPhoto(
console.error('Failed to parse image from URI');
return null;
}
console.error(`[export] decode ${Date.now() - t0}ms`);
let skImage = skImage0;
let width = skImage.width();
let height = skImage.height();
console.error(`[export] BEGIN ${width}x${height} ev=${recipe.adjustments.exposureCompensation ?? 0} evFromCamera=${options?.evFromCamera ?? false}`);
// 1b. Aspect-ratio crop (composition): center-largest upright crop of the
// requested ratio. Runs BEFORE any processing so frame/GPS/grain/sharpen
// all apply to exactly the pixels the user framed. Wall Frame is exempt —
@@ -144,7 +137,6 @@ export async function processAndExportPhoto(
skImage = cropped;
width = cropW;
height = cropH;
console.error(`[export] aspect-crop ${width}x${height}`);
}
// 2. Create offscreen canvas (GPU-backed when available — see createSurface)
@@ -181,7 +173,7 @@ export async function processAndExportPhoto(
try {
toneEffect = Skia.RuntimeEffect.Make(TONE_SKSL);
} catch (e) {
console.error('[export] TONE_SKSL compile failed - falling back to matrix-only: ' + e);
console.error('TONE_SKSL compile failed - falling back to matrix-only: ' + e);
}
if (toneEffect) {
const imageShader = skImage.makeShaderOptions(
@@ -194,10 +186,10 @@ export async function processAndExportPhoto(
if (toneShader) {
paint.setShader(toneShader);
} else {
console.error('[export] tone shader build failed - falling back to matrix-only');
console.error('Tone shader build failed - falling back to matrix-only');
}
} else {
console.error('[export] TONE_SKSL compile failed - falling back to matrix-only');
console.error('TONE_SKSL compile failed - falling back to matrix-only');
}
}
@@ -222,7 +214,7 @@ export async function processAndExportPhoto(
}
}
} catch (e) {
console.error('[export] CINEMA_SKSL failed - skipped: ' + e);
console.error('CINEMA_SKSL failed - skipped: ' + e);
}
}
@@ -462,7 +454,6 @@ export async function processAndExportPhoto(
}
// 9. Snapshot & Encode to JPEG (base64 string, no SkData round-trip)
console.error(`[export] composite ${Date.now() - t0}ms`);
let resultImage = outputSurface.makeImageSnapshot();
// Sharpening knob (0..10) overrides the legacy boolean sharpen flag:
// legacy camera captures keep sharpen:true → 0.5 fallback when knob is 0.
@@ -471,7 +462,6 @@ export async function processAndExportPhoto(
if (sharpenAmount > 0) {
resultImage = screenSharpenImage(resultImage, sharpenAmount);
}
console.error(`[export] sharpen ${Date.now() - t0}ms`);
// Encode straight to raw JPEG bytes — no base64. The old path round-tripped
// the whole 12MP frame through JS base64 decode/encode for the DPI patch and
// then writeAsStringAsync, which alone took ~6s of the render.
@@ -481,8 +471,6 @@ export async function processAndExportPhoto(
console.error('Failed to encode image to JPEG');
return null;
}
console.error(`[export] encode ${Date.now() - t0}ms`);
// 9b. Patch DPI metadata (JFIF density / EXIF resolution) when requested —
// in-place Uint8Array work, no base64 round-trip.
if (options?.dpi && options.dpi > 0) {
@@ -499,32 +487,23 @@ export async function processAndExportPhoto(
console.error('Failed to write export file: ' + e);
return null;
}
console.error(`[export] write ${Date.now() - t0}ms`);
console.error(`[export] total-done ${Date.now() - t0}ms`);
// 11. Request Media Library permission & Save to device gallery
// 11. Request Media Library permission & Save to device gallery.
// createAssetAsync (not saveToLibraryAsync) so the caller learns the
// gallery asset id — the photo viewer's trash button needs it to remove
// the saved copy. ponytail: old exports are NOT retired here anymore — the
// in-app photo history still references their cache files for the swipe
// viewer; the OS cache purge reclaims them eventually.
const mediaPermission = await MediaLibrary.requestPermissionsAsync();
if (mediaPermission.granted) {
try {
await MediaLibrary.saveToLibraryAsync(exportFile);
// New file is safely in the gallery — retire the previous export temp.
if (lastExportPath && lastExportPath !== exportFile) {
try {
await FileSystem.deleteAsync(lastExportPath, { idempotent: true });
} catch (e) {
console.warn('Cleanup of previous export temp failed:', e);
}
}
lastExportPath = exportFile;
return { uri: exportFile, savedToLibrary: true };
const asset = await MediaLibrary.createAssetAsync(exportFile);
return { uri: exportFile, savedToLibrary: true, assetId: asset.id };
} catch (e) {
console.warn('Save to gallery failed:', e);
}
} else {
console.warn('Media Library permission denied. Image saved to temporary cache only.');
}
console.error(`[export] gallery-save ${Date.now() - t0}ms`);
return { uri: exportFile, savedToLibrary: false };
} catch (error) {
console.error('Error during photo processing and export:', error);
+165
View File
@@ -0,0 +1,165 @@
import { ColorAdjustments } from '../types';
// Static metadata for the continuous (slider) adjustments of the IQ/WB/FILTERS
// tabs — shared between AdjustmentPanel (chips + slider rows) and App (which
// needs the open row's bounds to drive drag-on-the-image adjustment). The get
// /set accessors are stateless so one table serves every render.
export interface ContinuousParamDef {
key: string;
label: string;
min: number;
max: number;
step: number;
defaultValue: number;
display: (v: number) => string;
get: (a: ColorAdjustments) => number;
set: (v: number) => Partial<ColorAdjustments>;
}
const sign = (v: number) => (v > 0 ? `+${v}` : String(v));
const oneEv = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(1)} EV`;
export const PARAM_DEFS: {
iq: ContinuousParamDef[];
wb: ContinuousParamDef[];
filters: ContinuousParamDef[];
} = {
iq: [
{
key: 'exposure',
label: 'EXPOSURE',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: sign,
get: (a) => a.exposure,
set: (v) => ({ exposure: v }),
},
{
key: 'ev',
label: 'EV',
min: -3,
max: 3,
step: 0.5,
defaultValue: 0,
display: oneEv,
get: (a) => a.exposureCompensation ?? 0,
set: (v) => ({ exposureCompensation: v }),
},
{
key: 'contrast',
label: 'CONTRAST',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: sign,
get: (a) => a.contrast,
set: (v) => ({ contrast: v }),
},
{
key: 'color',
label: 'COLOR',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: sign,
get: (a) => a.saturation,
set: (v) => ({ saturation: v }),
},
{
key: 'highlight',
label: 'HIGHLIGHT',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: sign,
get: (a) => a.highlight ?? 0,
set: (v) => ({ highlight: v }),
},
{
key: 'shadow',
label: 'SHADOW',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: sign,
get: (a) => a.shadow ?? 0,
set: (v) => ({ shadow: v }),
},
],
wb: [
{
key: 'temperature',
label: 'COLOR TEMP',
min: 2500,
max: 10000,
step: 100,
defaultValue: 5500,
display: (v) => `${v}K`,
get: (a) => a.temperature,
set: (v) => ({ temperature: v }),
},
{
key: 'tint',
label: 'TINT',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: sign,
get: (a) => a.tint ?? 0,
set: (v) => ({ tint: v }),
},
],
filters: [
{
key: 'denoise',
label: 'NOISE REDUCTION',
min: 0,
max: 10,
step: 1,
defaultValue: 0,
display: String,
get: (a) => a.denoise,
set: (v) => ({ denoise: v }),
},
{
key: 'clarity',
label: 'CLARITY',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: sign,
get: (a) => a.clarity,
set: (v) => ({ clarity: v }),
},
{
key: 'sharpening',
label: 'SHARPENING',
min: 0,
max: 10,
step: 1,
defaultValue: 0,
display: String,
get: (a) => a.sharpening ?? 0,
set: (v) => ({ sharpening: v }),
},
{
key: 'grain',
label: 'MONOCHROME GRAIN',
min: 0,
max: 10,
step: 1,
defaultValue: 0,
display: String,
get: (a) => a.grain,
set: (v) => ({ grain: v }),
},
],
};
+7 -10
View File
@@ -52,16 +52,16 @@ async function resolveFromMediaLibrary(
try {
perm = await MediaLibrary.requestPermissionsAsync(false, ['photo']);
} catch (e) {
console.error('[gps] tier2 MediaLibrary permission request failed: ' + String(e).slice(0, 120));
console.error('Media library permission request failed (photo GPS): ' + String(e).slice(0, 120));
return { gps: null, permissionDenied: true };
}
if (!perm.granted) {
console.error('[gps] tier2 MediaLibrary permission denied');
console.error('Media library read permission denied (photo GPS)');
return { gps: null, permissionDenied: true };
}
const id = pickerAssetId(asset);
if (!id) {
console.error('[gps] tier2 no assetId to query MediaStore');
console.warn('Photo has no media-library asset id — GPS lookup skipped');
return { gps: null, permissionDenied: false };
}
try {
@@ -69,7 +69,6 @@ async function resolveFromMediaLibrary(
if (info.location && Number.isFinite(info.location.latitude) && Number.isFinite(info.location.longitude)) {
const { latitude, longitude } = info.location;
const locality = useGeotag ? await localityFor(latitude, longitude) : undefined;
console.error(`[gps] tier2 MediaStore location lat=${latitude.toFixed(5)} lon=${longitude.toFixed(5)}`);
return {
gps: {
latitude,
@@ -82,9 +81,9 @@ async function resolveFromMediaLibrary(
permissionDenied: false,
};
}
console.error('[gps] tier2 MediaStore asset has no location (photo may have no GPS, or ACCESS_MEDIA_LOCATION read failed silently)');
console.warn('Media library photo has no location (no GPS, or ACCESS_MEDIA_LOCATION denied silently)');
} catch (e) {
console.error('[gps] tier2 getAssetInfoAsync failed: ' + String(e).slice(0, 160));
console.error('Media library asset lookup failed (photo GPS): ' + String(e).slice(0, 160));
}
return { gps: null, permissionDenied: false };
}
@@ -102,12 +101,11 @@ export async function resolvePhotoGPS(
if (exifCoord) {
const timestamp = parseExifTimestamp(asset.exif) ?? Date.now();
const locality = useGeotag ? await localityFor(exifCoord.latitude, exifCoord.longitude) : undefined;
console.error(`[gps] tier1 EXIF lat=${exifCoord.latitude.toFixed(5)} lon=${exifCoord.longitude.toFixed(5)}`);
return {
gps: { ...exifCoord, locality, country: undefined, timestamp, source: 'exif' },
};
}
console.error('[gps] tier1 no EXIF GPS in picker metadata');
console.warn('No EXIF GPS in picker metadata');
// Tier 2 — MediaLibrary metadata (Android 10+ requires ACCESS_MEDIA_LOCATION)
const assetId = pickerAssetId(asset);
@@ -116,14 +114,13 @@ export async function resolvePhotoGPS(
if (gps) return { gps };
if (permissionDenied) return { gps: null, permissionDenied: true };
} else {
console.error('[gps] tier2 skipped — no asset id/uri');
console.warn('Photo GPS tier-2 skipped — no asset id/uri');
}
// Tier 3 — device fallback (photo has no location; stamp the device's current spot)
if (allowDeviceFallback) {
const deviceGps = await getCurrentGPS();
if (deviceGps) {
console.error(`[gps] tier3 DEVICE GPS fallback lat=${deviceGps.latitude.toFixed(5)} lon=${deviceGps.longitude.toFixed(5)}`);
return { gps: { ...deviceGps, source: 'device' } };
}
}