refactor: move mobile app to bincio-autarchive repo
This commit is contained in:
@@ -1,18 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
.expo/
|
|
||||||
dist/
|
|
||||||
npm-debug.*
|
|
||||||
*.jks
|
|
||||||
*.p8
|
|
||||||
*.p12
|
|
||||||
*.key
|
|
||||||
*.mobileprovision
|
|
||||||
*.orig.*
|
|
||||||
web-build/
|
|
||||||
|
|
||||||
# Generated native projects (managed workflow — produced by EAS, not committed)
|
|
||||||
android/
|
|
||||||
ios/
|
|
||||||
|
|
||||||
# Local env overrides
|
|
||||||
.env.local
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { createContext, useContext, useEffect, useState } from 'react';
|
|
||||||
import { useSQLiteContext } from 'expo-sqlite';
|
|
||||||
import { getSetting, setSetting } from '@/db/queries';
|
|
||||||
import { autoKey, PALETTES, type PaletteKey, type Theme } from '@/theme';
|
|
||||||
|
|
||||||
type ThemeCtx = {
|
|
||||||
theme: Theme;
|
|
||||||
paletteKey: PaletteKey;
|
|
||||||
setPaletteOverride: (key: PaletteKey) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeCtx>({
|
|
||||||
theme: PALETTES.default,
|
|
||||||
paletteKey: 'auto',
|
|
||||||
setPaletteOverride: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|
||||||
const db = useSQLiteContext();
|
|
||||||
const [paletteKey, setPaletteKey] = useState<PaletteKey>('auto');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getSetting(db, 'palette_override').then(val => {
|
|
||||||
if (val) setPaletteKey(val as PaletteKey);
|
|
||||||
});
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function setPaletteOverride(key: PaletteKey) {
|
|
||||||
setPaletteKey(key);
|
|
||||||
setSetting(db, 'palette_override', key);
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolved = paletteKey === 'auto' ? autoKey() : paletteKey;
|
|
||||||
const theme = PALETTES[resolved as keyof typeof PALETTES] ?? PALETTES.default;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeContext.Provider value={{ theme, paletteKey, setPaletteOverride }}>
|
|
||||||
{children}
|
|
||||||
</ThemeContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTheme(): Theme {
|
|
||||||
return useContext(ThemeContext).theme;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePaletteControl(): Pick<ThemeCtx, 'paletteKey' | 'setPaletteOverride'> {
|
|
||||||
const { paletteKey, setPaletteOverride } = useContext(ThemeContext);
|
|
||||||
return { paletteKey, setPaletteOverride };
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
apply plugin: "com.android.application"
|
|
||||||
apply plugin: "org.jetbrains.kotlin.android"
|
|
||||||
apply plugin: "com.facebook.react"
|
|
||||||
|
|
||||||
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This is the configuration block to customize your React Native Android app.
|
|
||||||
* By default you don't need to apply any configuration, just uncomment the lines you need.
|
|
||||||
*/
|
|
||||||
react {
|
|
||||||
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
|
|
||||||
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
|
|
||||||
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
|
|
||||||
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
|
|
||||||
|
|
||||||
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
|
|
||||||
// Use Expo CLI to bundle the app, this ensures the Metro config
|
|
||||||
// works correctly with Expo projects.
|
|
||||||
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
|
|
||||||
bundleCommand = "export:embed"
|
|
||||||
|
|
||||||
// Embed the JS bundle in debug builds so the APK runs without a Metro server
|
|
||||||
// (needed for deployment to Karoo and other standalone devices).
|
|
||||||
// debuggableVariants lists variants that skip bundling; empty = bundle all variants.
|
|
||||||
debuggableVariants = []
|
|
||||||
|
|
||||||
/* Folders */
|
|
||||||
// The root of your project, i.e. where "package.json" lives. Default is '../..'
|
|
||||||
// root = file("../../")
|
|
||||||
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
|
|
||||||
// reactNativeDir = file("../../node_modules/react-native")
|
|
||||||
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
|
|
||||||
// codegenDir = file("../../node_modules/@react-native/codegen")
|
|
||||||
|
|
||||||
/* Variants */
|
|
||||||
// The list of variants to that are debuggable. For those we're going to
|
|
||||||
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
|
|
||||||
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
|
|
||||||
// debuggableVariants = ["liteDebug", "prodDebug"]
|
|
||||||
|
|
||||||
/* Bundling */
|
|
||||||
// A list containing the node command and its flags. Default is just 'node'.
|
|
||||||
// nodeExecutableAndArgs = ["node"]
|
|
||||||
|
|
||||||
//
|
|
||||||
// The path to the CLI configuration file. Default is empty.
|
|
||||||
// bundleConfig = file(../rn-cli.config.js)
|
|
||||||
//
|
|
||||||
// The name of the generated asset file containing your JS bundle
|
|
||||||
// bundleAssetName = "MyApplication.android.bundle"
|
|
||||||
//
|
|
||||||
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
|
|
||||||
// entryFile = file("../js/MyApplication.android.js")
|
|
||||||
//
|
|
||||||
// A list of extra flags to pass to the 'bundle' commands.
|
|
||||||
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
|
|
||||||
// extraPackagerArgs = []
|
|
||||||
|
|
||||||
/* Hermes Commands */
|
|
||||||
// The hermes compiler command to run. By default it is 'hermesc'
|
|
||||||
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
|
|
||||||
//
|
|
||||||
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
|
|
||||||
// hermesFlags = ["-O", "-output-source-map"]
|
|
||||||
|
|
||||||
/* Autolinking */
|
|
||||||
autolinkLibrariesWithApp()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization).
|
|
||||||
*/
|
|
||||||
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The preferred build flavor of JavaScriptCore (JSC)
|
|
||||||
*
|
|
||||||
* For example, to use the international variant, you can use:
|
|
||||||
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
|
|
||||||
*
|
|
||||||
* The international variant includes ICU i18n library and necessary data
|
|
||||||
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
|
|
||||||
* give correct results when using with locales other than en-US. Note that
|
|
||||||
* this variant is about 6MiB larger per architecture than default.
|
|
||||||
*/
|
|
||||||
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
|
|
||||||
|
|
||||||
android {
|
|
||||||
ndkVersion rootProject.ext.ndkVersion
|
|
||||||
|
|
||||||
buildToolsVersion rootProject.ext.buildToolsVersion
|
|
||||||
compileSdk rootProject.ext.compileSdkVersion
|
|
||||||
|
|
||||||
namespace 'org.bincio.app'
|
|
||||||
defaultConfig {
|
|
||||||
applicationId 'org.bincio.app'
|
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
|
||||||
versionCode 1
|
|
||||||
versionName "0.1.0"
|
|
||||||
|
|
||||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
|
||||||
}
|
|
||||||
signingConfigs {
|
|
||||||
debug {
|
|
||||||
storeFile file('debug.keystore')
|
|
||||||
storePassword 'android'
|
|
||||||
keyAlias 'androiddebugkey'
|
|
||||||
keyPassword 'android'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buildTypes {
|
|
||||||
debug {
|
|
||||||
signingConfig signingConfigs.debug
|
|
||||||
}
|
|
||||||
release {
|
|
||||||
// Caution! In production, you need to generate your own keystore file.
|
|
||||||
// see https://reactnative.dev/docs/signed-apk-android.
|
|
||||||
signingConfig signingConfigs.debug
|
|
||||||
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
|
|
||||||
shrinkResources enableShrinkResources.toBoolean()
|
|
||||||
minifyEnabled enableMinifyInReleaseBuilds
|
|
||||||
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
|
||||||
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
|
|
||||||
crunchPngs enablePngCrunchInRelease.toBoolean()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
packagingOptions {
|
|
||||||
jniLibs {
|
|
||||||
def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
|
|
||||||
useLegacyPackaging enableLegacyPackaging.toBoolean()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
splits {
|
|
||||||
abi {
|
|
||||||
enable true
|
|
||||||
reset()
|
|
||||||
include "arm64-v8a", "armeabi-v7a"
|
|
||||||
universalApk true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
androidResources {
|
|
||||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply static values from `gradle.properties` to the `android.packagingOptions`
|
|
||||||
// Accepts values in comma delimited lists, example:
|
|
||||||
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
|
|
||||||
["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop ->
|
|
||||||
// Split option: 'foo,bar' -> ['foo', 'bar']
|
|
||||||
def options = (findProperty("android.packagingOptions.$prop") ?: "").split(",");
|
|
||||||
// Trim all elements in place.
|
|
||||||
for (i in 0..<options.size()) options[i] = options[i].trim();
|
|
||||||
// `[] - ""` is essentially `[""].filter(Boolean)` removing all empty strings.
|
|
||||||
options -= ""
|
|
||||||
|
|
||||||
if (options.length > 0) {
|
|
||||||
println "android.packagingOptions.$prop += $options ($options.length)"
|
|
||||||
// Ex: android.packagingOptions.pickFirsts += '**/SCCS/**'
|
|
||||||
options.each {
|
|
||||||
android.packagingOptions[prop] += it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
// The version of react-native is set by the React Native Gradle Plugin
|
|
||||||
implementation("com.facebook.react:react-android")
|
|
||||||
|
|
||||||
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
|
|
||||||
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
|
|
||||||
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
|
|
||||||
|
|
||||||
if (isGifEnabled) {
|
|
||||||
// For animated gif support
|
|
||||||
implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isWebpEnabled) {
|
|
||||||
// For webp support
|
|
||||||
implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}")
|
|
||||||
if (isWebpAnimatedEnabled) {
|
|
||||||
// Animated webp support
|
|
||||||
implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hermesEnabled.toBoolean()) {
|
|
||||||
implementation("com.facebook.react:hermes-android")
|
|
||||||
} else {
|
|
||||||
implementation jscFlavor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
{
|
|
||||||
"expo": {
|
|
||||||
"name": "Bincio",
|
|
||||||
"slug": "bincio",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"orientation": "portrait",
|
|
||||||
"scheme": "bincio",
|
|
||||||
"userInterfaceStyle": "dark",
|
|
||||||
"newArchEnabled": true,
|
|
||||||
"platforms": ["ios", "android"],
|
|
||||||
"icon": "./assets/icon.png",
|
|
||||||
"splash": {
|
|
||||||
"image": "./assets/splash-icon.png",
|
|
||||||
"resizeMode": "contain",
|
|
||||||
"backgroundColor": "#09090b"
|
|
||||||
},
|
|
||||||
"android": {
|
|
||||||
"package": "org.bincio.app",
|
|
||||||
"usesCleartextTraffic": true,
|
|
||||||
"adaptiveIcon": {
|
|
||||||
"foregroundImage": "./assets/adaptive-icon.png",
|
|
||||||
"backgroundColor": "#09090b"
|
|
||||||
},
|
|
||||||
"permissions": [
|
|
||||||
"android.permission.READ_EXTERNAL_STORAGE",
|
|
||||||
"android.permission.READ_MEDIA_VIDEO",
|
|
||||||
"android.permission.RECEIVE_BOOT_COMPLETED",
|
|
||||||
"android.permission.VIBRATE",
|
|
||||||
"android.permission.POST_NOTIFICATIONS"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"ios": {
|
|
||||||
"bundleIdentifier": "org.bincio.app",
|
|
||||||
"supportsTablet": true
|
|
||||||
},
|
|
||||||
"plugins": [
|
|
||||||
"expo-system-ui",
|
|
||||||
"expo-router",
|
|
||||||
"expo-sqlite",
|
|
||||||
[
|
|
||||||
"expo-document-picker",
|
|
||||||
{ "iCloudContainerEnvironment": "Production" }
|
|
||||||
],
|
|
||||||
"expo-background-fetch",
|
|
||||||
"expo-task-manager",
|
|
||||||
"@maplibre/maplibre-react-native"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { Tabs } from 'expo-router';
|
|
||||||
import { Platform } from 'react-native';
|
|
||||||
import { useTheme } from '@/ThemeContext';
|
|
||||||
|
|
||||||
const isKaroo = Platform.OS === 'android' && (Platform.Version as number) < 29;
|
|
||||||
|
|
||||||
export default function TabLayout() {
|
|
||||||
const theme = useTheme();
|
|
||||||
return (
|
|
||||||
<Tabs
|
|
||||||
screenOptions={{
|
|
||||||
headerShown: false,
|
|
||||||
tabBarStyle: { backgroundColor: '#18181b', borderTopColor: '#27272a' },
|
|
||||||
tabBarActiveTintColor: theme.accent,
|
|
||||||
tabBarInactiveTintColor: '#71717a',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="index"
|
|
||||||
options={{ title: 'Feed', tabBarIcon: ({ color }) => <TabIcon label="⬡" color={color} /> }}
|
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="import"
|
|
||||||
options={{ title: 'Import', tabBarIcon: ({ color }) => <TabIcon label="↑" color={color} /> }}
|
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="search"
|
|
||||||
options={{
|
|
||||||
title: 'Search',
|
|
||||||
tabBarIcon: ({ color }) => <TabIcon label="⌕" color={color} />,
|
|
||||||
href: isKaroo ? null : '/search',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="settings"
|
|
||||||
options={{ title: 'Settings', tabBarIcon: ({ color }) => <TabIcon label="⚙" color={color} /> }}
|
|
||||||
/>
|
|
||||||
</Tabs>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabIcon({ label, color }: { label: string; color: string }) {
|
|
||||||
const { Text } = require('react-native');
|
|
||||||
return <Text style={{ color, fontSize: 18 }}>{label}</Text>;
|
|
||||||
}
|
|
||||||
@@ -1,604 +0,0 @@
|
|||||||
import * as DocumentPicker from 'expo-document-picker';
|
|
||||||
import * as FileSystem from 'expo-file-system/legacy';
|
|
||||||
import { useFocusEffect } from 'expo-router';
|
|
||||||
import { useSQLiteContext } from 'expo-sqlite';
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import { AppState, PermissionsAndroid, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
|
||||||
import { insertActivity, isSourcePathImported, getSetting } from '@/db/queries';
|
|
||||||
import { PyodideWebView } from '@/extraction/PyodideWebView';
|
|
||||||
import { extractFile, waitForEngine, onEngineProgress, isEngineAvailable } from '@/extraction/extractActivity';
|
|
||||||
import { extractFileViaServer, checkServerAuth } from '@/extraction/extractServer';
|
|
||||||
import { useTheme } from '@/ThemeContext';
|
|
||||||
|
|
||||||
async function sha256hex(text: string): Promise<string> {
|
|
||||||
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
|
||||||
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
const FIT_EXTENSIONS = ['.fit', '.fit.gz'];
|
|
||||||
const OTHER_EXTENSIONS = ['.gpx', '.tcx', '.gpx.gz', '.tcx.gz'];
|
|
||||||
const ALL_NATIVE_EXTENSIONS = [...FIT_EXTENSIONS, ...OTHER_EXTENSIONS];
|
|
||||||
|
|
||||||
type ImportState =
|
|
||||||
| { status: 'idle' }
|
|
||||||
| { status: 'loading'; msg: string; current: number; total: number }
|
|
||||||
| { status: 'done'; count: number; errors: Array<{ name: string; message: string }> }
|
|
||||||
| { status: 'error'; message: string };
|
|
||||||
|
|
||||||
export default function ImportScreen() {
|
|
||||||
const db = useSQLiteContext();
|
|
||||||
const theme = useTheme();
|
|
||||||
const [state, setState] = useState<ImportState>({ status: 'idle' });
|
|
||||||
const [watchPath, setWatchPath] = useState('');
|
|
||||||
const [engineAvailable, setEngineAvailable] = useState<boolean | null>(null);
|
|
||||||
const isImporting = useRef(false);
|
|
||||||
|
|
||||||
// Track engine availability so we can show the server-extraction notice.
|
|
||||||
useEffect(() => {
|
|
||||||
waitForEngine(30_000)
|
|
||||||
.then(() => setEngineAvailable(true))
|
|
||||||
.catch((e: unknown) => {
|
|
||||||
if (e instanceof Error && e.message === 'engine_unavailable') setEngineAvailable(false);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Reload watch path every time the Import tab comes into focus so changes
|
|
||||||
// saved in Settings are picked up without remounting the tab.
|
|
||||||
useFocusEffect(useCallback(() => {
|
|
||||||
if (Platform.OS !== 'android') return;
|
|
||||||
const row = db.getFirstSync<{ value: string }>(
|
|
||||||
'SELECT value FROM settings WHERE key = ?',
|
|
||||||
['auto_import_path'],
|
|
||||||
);
|
|
||||||
setWatchPath(row?.value ?? '');
|
|
||||||
}, [db]));
|
|
||||||
|
|
||||||
// Auto-scan watch folder on mount and when app comes to foreground.
|
|
||||||
useEffect(() => {
|
|
||||||
if (Platform.OS !== 'android') return;
|
|
||||||
runAutoScan();
|
|
||||||
|
|
||||||
const sub = AppState.addEventListener('change', (next) => {
|
|
||||||
if (next === 'active') runAutoScan();
|
|
||||||
});
|
|
||||||
return () => sub.remove();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function runAutoScan() {
|
|
||||||
if (isImporting.current) return;
|
|
||||||
const path = await getSetting(db, 'auto_import_path');
|
|
||||||
if (!path) return;
|
|
||||||
const instanceUrl = await getSetting(db, 'instance_url');
|
|
||||||
if (!instanceUrl) return;
|
|
||||||
|
|
||||||
// Wait for engine — skip auto-scan on init failure, but continue if device is
|
|
||||||
// too old for local extraction (importNativeFile will use the server instead).
|
|
||||||
try { await waitForEngine(120_000); } catch (e: unknown) {
|
|
||||||
if (!(e instanceof Error) || e.message !== 'engine_unavailable') return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server-mode requires a valid token — verify before touching any files.
|
|
||||||
if (isEngineAvailable() === false) {
|
|
||||||
const token = await getSetting(db, 'api_token');
|
|
||||||
if (!token) return;
|
|
||||||
try { await checkServerAuth(instanceUrl, token); } catch { return; }
|
|
||||||
}
|
|
||||||
|
|
||||||
const newFiles = await discoverNewFiles(db, path);
|
|
||||||
if (newFiles.length === 0) return;
|
|
||||||
|
|
||||||
isImporting.current = true;
|
|
||||||
try {
|
|
||||||
await processBatch(newFiles.map(f => ({ uri: `file://${f}`, name: f.split('/').pop() ?? f, sourcePath: f })));
|
|
||||||
} finally {
|
|
||||||
isImporting.current = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function manualScan() {
|
|
||||||
if (isImporting.current) return;
|
|
||||||
const path = await getSetting(db, 'auto_import_path');
|
|
||||||
if (!path) return;
|
|
||||||
const instanceUrl = await getSetting(db, 'instance_url');
|
|
||||||
if (!instanceUrl) {
|
|
||||||
setState({ status: 'error', message: 'No Bincio instance configured. Go to Settings and enter an instance URL first — it\'s needed to download the extraction engine.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const serverMode = isEngineAvailable() === false;
|
|
||||||
if (!serverMode) {
|
|
||||||
setState({ status: 'loading', msg: 'Preparing extraction engine…', current: 0, total: 0 });
|
|
||||||
const unsubScan = onEngineProgress((msg) =>
|
|
||||||
setState({ status: 'loading', msg, current: 0, total: 0 }),
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await waitForEngine();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
if (!(e instanceof Error) || e.message !== 'engine_unavailable') {
|
|
||||||
setState({ status: 'error', message: e instanceof Error ? e.message : String(e) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// engine_unavailable — fall through to server mode
|
|
||||||
} finally {
|
|
||||||
unsubScan();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const token = await getSetting(db, 'api_token');
|
|
||||||
if (!token) {
|
|
||||||
setState({ status: 'error', message: 'Server extraction requires a Bincio account. Connect in Settings.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Verify the token is valid before processing any files.
|
|
||||||
setState({ status: 'loading', msg: 'Checking connection…', current: 0, total: 0 });
|
|
||||||
try {
|
|
||||||
await checkServerAuth(instanceUrl, token);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
setState({ status: 'error', message: e instanceof Error ? e.message : String(e) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setState({ status: 'loading', msg: 'Scanning…', current: 0, total: 0 });
|
|
||||||
const newFiles = await discoverNewFiles(db, path);
|
|
||||||
if (newFiles.length === 0) {
|
|
||||||
setState({ status: 'done', count: 0, errors: [] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isImporting.current = true;
|
|
||||||
try {
|
|
||||||
await processBatch(newFiles.map(f => ({ uri: `file://${f}`, name: f.split('/').pop() ?? f, sourcePath: f })));
|
|
||||||
} finally {
|
|
||||||
isImporting.current = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pickFiles() {
|
|
||||||
if (isImporting.current) return;
|
|
||||||
setState({ status: 'loading', msg: 'Picking files…', current: 0, total: 0 });
|
|
||||||
try {
|
|
||||||
let result: DocumentPicker.DocumentPickerResult;
|
|
||||||
try {
|
|
||||||
result = await DocumentPicker.getDocumentAsync({
|
|
||||||
type: ['*/*'],
|
|
||||||
copyToCacheDirectory: true,
|
|
||||||
multiple: true,
|
|
||||||
});
|
|
||||||
} catch (pickerErr: unknown) {
|
|
||||||
// Some Android devices (e.g. Karoo) have no system file picker app.
|
|
||||||
const raw = pickerErr instanceof Error ? pickerErr.message : String(pickerErr);
|
|
||||||
const noApp = raw.includes('ActivityNotFoundException') || raw.includes('No Activity found');
|
|
||||||
setState({
|
|
||||||
status: 'error',
|
|
||||||
message: noApp
|
|
||||||
? 'No file picker available on this device. Set a Watch directory in Settings to import from a folder.'
|
|
||||||
: raw,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.canceled || !result.assets?.length) {
|
|
||||||
setState({ status: 'idle' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
isImporting.current = true;
|
|
||||||
const unsubPick = onEngineProgress((msg) =>
|
|
||||||
setState({ status: 'loading', msg, current: 0, total: 0 }),
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await processBatch(result.assets.map(a => ({ uri: a.uri, name: a.name ?? '', sourcePath: null })));
|
|
||||||
} finally {
|
|
||||||
unsubPick();
|
|
||||||
isImporting.current = false;
|
|
||||||
}
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
|
||||||
setState({ status: 'error', message: msg });
|
|
||||||
isImporting.current = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processBatch(files: Array<{ uri: string; name: string; sourcePath: string | null }>) {
|
|
||||||
const total = files.length;
|
|
||||||
const errors: Array<{ name: string; message: string }> = [];
|
|
||||||
let count = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
|
||||||
const { uri, name, sourcePath } = files[i];
|
|
||||||
const lower = name.toLowerCase();
|
|
||||||
|
|
||||||
setState({ status: 'loading', msg: `Processing ${name}…`, current: i + 1, total });
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (lower.endsWith('.json')) {
|
|
||||||
await importBasJson(uri, name, sourcePath, (msg) =>
|
|
||||||
setState({ status: 'loading', msg, current: i + 1, total }),
|
|
||||||
);
|
|
||||||
} else if (ALL_NATIVE_EXTENSIONS.some(ext => lower.endsWith(ext))) {
|
|
||||||
await importNativeFile(uri, name, sourcePath, (msg) =>
|
|
||||||
setState({ status: 'loading', msg, current: i + 1, total }),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
errors.push({ name, message: 'Unsupported file type' });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
count++;
|
|
||||||
} catch (e: unknown) {
|
|
||||||
errors.push({ name, message: e instanceof Error ? e.message : String(e) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setState({ status: 'done', count, errors });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── BAS JSON import (no extraction needed) ──────────────────────────────────
|
|
||||||
|
|
||||||
async function importBasJson(
|
|
||||||
uri: string,
|
|
||||||
_name: string,
|
|
||||||
sourcePath: string | null,
|
|
||||||
onStatus: (msg: string) => void,
|
|
||||||
) {
|
|
||||||
onStatus('Importing…');
|
|
||||||
const text = await FileSystem.readAsStringAsync(uri);
|
|
||||||
const detail = JSON.parse(text);
|
|
||||||
|
|
||||||
if (!detail.id || !detail.started_at) {
|
|
||||||
throw new Error('Not a valid BAS activity JSON (missing id or started_at)');
|
|
||||||
}
|
|
||||||
|
|
||||||
const hash = detail.source_hash ?? await sha256hex(text);
|
|
||||||
const origDir = `${FileSystem.documentDirectory}originals/`;
|
|
||||||
await FileSystem.makeDirectoryAsync(origDir, { intermediates: true });
|
|
||||||
const dest = `${origDir}${detail.id}.json`;
|
|
||||||
await FileSystem.copyAsync({ from: uri, to: dest });
|
|
||||||
|
|
||||||
await insertActivity(db, {
|
|
||||||
id: detail.id,
|
|
||||||
source_hash: hash,
|
|
||||||
detail_json: text,
|
|
||||||
timeseries_json: null,
|
|
||||||
geojson: null,
|
|
||||||
original_path: dest,
|
|
||||||
source_path: sourcePath,
|
|
||||||
origin: 'local',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── FIT / GPX / TCX import via Pyodide (local) or server fallback ───────────
|
|
||||||
|
|
||||||
async function importNativeFile(
|
|
||||||
uri: string,
|
|
||||||
name: string,
|
|
||||||
sourcePath: string | null,
|
|
||||||
onStatus: (msg: string) => void,
|
|
||||||
) {
|
|
||||||
onStatus('Reading file…');
|
|
||||||
|
|
||||||
// Read the original file as base64 so we can (a) pass it to the extractor
|
|
||||||
// and (b) copy it to permanent storage without a second read.
|
|
||||||
const base64 = await FileSystem.readAsStringAsync(uri, {
|
|
||||||
encoding: FileSystem.EncodingType.Base64,
|
|
||||||
});
|
|
||||||
|
|
||||||
let result;
|
|
||||||
|
|
||||||
if (isEngineAvailable() === false) {
|
|
||||||
// Device WebView is too old for WebAssembly.Global (Chrome <69).
|
|
||||||
// Send the raw file to the Bincio instance for server-side extraction.
|
|
||||||
const instanceUrl = await getInstanceUrl(db);
|
|
||||||
const token = db.getFirstSync<{ value: string }>(
|
|
||||||
'SELECT value FROM settings WHERE key = ?', ['api_token'],
|
|
||||||
)?.value ?? '';
|
|
||||||
if (!token) throw new Error('Server extraction requires a Bincio account — connect in Settings.');
|
|
||||||
result = await extractFileViaServer(name, base64, instanceUrl, token, onStatus);
|
|
||||||
} else {
|
|
||||||
// Fetch the bincio wheel here (React Native networking), not inside the
|
|
||||||
// WebView. WKWebView blocks HTTP requests via ATS; RN native networking
|
|
||||||
// allows local-network HTTP (NSAllowsLocalNetworking=true in Info.plist).
|
|
||||||
const instanceUrl = await getInstanceUrl(db);
|
|
||||||
onStatus('Fetching Bincio engine…');
|
|
||||||
const { base64: wheelBase64, filename: wheelFilename } = await fetchWheelBase64(instanceUrl);
|
|
||||||
result = await extractFile(name, base64, wheelBase64, wheelFilename, onStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
onStatus('Saving…');
|
|
||||||
|
|
||||||
// Copy original file to permanent storage (keeps original bytes for future re-extraction)
|
|
||||||
const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')) : '';
|
|
||||||
const origDir = `${FileSystem.documentDirectory}originals/`;
|
|
||||||
await FileSystem.makeDirectoryAsync(origDir, { intermediates: true });
|
|
||||||
const dest = `${origDir}${result.id}${ext}`;
|
|
||||||
await FileSystem.copyAsync({ from: uri, to: dest });
|
|
||||||
|
|
||||||
await insertActivity(db, {
|
|
||||||
id: result.id,
|
|
||||||
source_hash: result.sourceHash,
|
|
||||||
detail_json: JSON.stringify(result.detail),
|
|
||||||
timeseries_json: result.timeseries ? JSON.stringify(result.timeseries) : null,
|
|
||||||
geojson: result.geojson ? JSON.stringify(result.geojson) : null,
|
|
||||||
original_path: dest,
|
|
||||||
source_path: sourcePath,
|
|
||||||
origin: 'local',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.screen}>
|
|
||||||
{/* Hidden WebView for Pyodide — only mounted on devices that can run it.
|
|
||||||
Android <29 has a system WebView (Chrome <69) that lacks WebAssembly.Global
|
|
||||||
AND causes GPU SurfaceView crashes on old drivers. Skip it entirely there. */}
|
|
||||||
{(Platform.OS !== 'android' || (Platform.Version as number) >= 29) && (
|
|
||||||
<View style={styles.hiddenEngine}>
|
|
||||||
<PyodideWebView />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
|
||||||
<Text style={styles.header}>Import</Text>
|
|
||||||
|
|
||||||
<Text style={styles.body}>
|
|
||||||
Import FIT, GPX, or TCX files — extracted on your device, nothing uploaded.
|
|
||||||
You can also import pre-extracted BAS <Text style={[styles.code, { color: theme.accent }]}>.json</Text> files.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{engineAvailable === false && (
|
|
||||||
<View style={styles.serverNotice}>
|
|
||||||
<Text style={styles.serverNoticeText}>
|
|
||||||
This device's Android WebView is too old to run local extraction (requires Chrome 69+).
|
|
||||||
Activities are processed by your Bincio instance instead — a connected account is required.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{watchPath ? (
|
|
||||||
<View style={styles.watchBox}>
|
|
||||||
<Text style={styles.watchLabel}>Watch folder</Text>
|
|
||||||
<Text style={styles.watchPath} numberOfLines={2}>{watchPath}</Text>
|
|
||||||
<Pressable
|
|
||||||
style={[styles.scanButton, state.status === 'loading' && styles.buttonDisabled]}
|
|
||||||
onPress={state.status !== 'loading' ? manualScan : undefined}
|
|
||||||
>
|
|
||||||
<Text style={styles.buttonText}>
|
|
||||||
{state.status === 'loading' ? 'Working…' : '↺ Scan for new rides'}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Pressable
|
|
||||||
style={[styles.button, state.status === 'loading' && styles.buttonDisabled]}
|
|
||||||
onPress={state.status !== 'loading' ? pickFiles : undefined}
|
|
||||||
>
|
|
||||||
<Text style={styles.buttonText}>
|
|
||||||
{state.status === 'loading' ? 'Working…' : '+ Pick files'}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
{state.status === 'loading' && (
|
|
||||||
<View style={styles.statusBox}>
|
|
||||||
{state.total > 1 && (
|
|
||||||
<Text style={styles.statusCounter}>
|
|
||||||
File {state.current} of {state.total}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<Text style={[styles.statusMsg, { color: theme.accent }]}>{state.msg}</Text>
|
|
||||||
{engineAvailable !== false && (
|
|
||||||
<Text style={styles.statusHint}>
|
|
||||||
First run downloads ~35 MB (Python runtime + packages). Subsequent runs are instant.
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{state.status === 'done' && (
|
|
||||||
<View style={[styles.success, state.count === 0 && state.errors.length === 0 && styles.successEmpty]}>
|
|
||||||
<Text style={styles.successText}>
|
|
||||||
{state.count === 0 && state.errors.length === 0
|
|
||||||
? 'No new rides found'
|
|
||||||
: `✓ Imported ${state.count} ${state.count === 1 ? 'activity' : 'activities'}`}
|
|
||||||
</Text>
|
|
||||||
{state.errors.map((e, i) => (
|
|
||||||
<Text key={i} style={styles.batchError}>✗ {e.name}: {e.message}</Text>
|
|
||||||
))}
|
|
||||||
<Pressable onPress={() => setState({ status: 'idle' })}>
|
|
||||||
<Text style={styles.errorRetry}>Dismiss</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{state.status === 'error' && (
|
|
||||||
<View style={styles.error}>
|
|
||||||
<Text style={styles.errorText}>{state.message}</Text>
|
|
||||||
<Pressable onPress={() => setState({ status: 'idle' })}>
|
|
||||||
<Text style={styles.errorRetry}>Try again</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={styles.divider} />
|
|
||||||
|
|
||||||
<Text style={styles.sectionTitle}>Supported formats</Text>
|
|
||||||
{([
|
|
||||||
['FIT', 'Garmin, Wahoo, Karoo native format'],
|
|
||||||
['GPX', 'Most GPS devices and apps'],
|
|
||||||
['TCX', 'Garmin Training Center'],
|
|
||||||
['BAS JSON', 'Pre-extracted Bincio format (instant)'],
|
|
||||||
] as [string, string][]).map(([fmt, desc]) => (
|
|
||||||
<View key={fmt} style={styles.formatRow}>
|
|
||||||
<Text style={styles.formatName}>{fmt}</Text>
|
|
||||||
<Text style={styles.formatDesc}>{desc}</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<View style={styles.notice}>
|
|
||||||
<Text style={styles.noticeText}>
|
|
||||||
{engineAvailable === false
|
|
||||||
? 'Activities are sent to your Bincio instance for extraction and stored there + locally. A connected account is required.'
|
|
||||||
: `FIT/GPX/TCX extraction runs entirely on your device.\nA Bincio instance must be reachable on first run to download the extraction engine (~35 MB, then cached).`}
|
|
||||||
{'\n\n'}
|
|
||||||
On Karoo: set Watch directory to <Text style={styles.noticeCode}>/sdcard/FitFiles</Text> in Settings to auto-import rides.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Watch-folder helpers ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function requestStoragePermission(): Promise<boolean> {
|
|
||||||
if (Platform.OS !== 'android') return true;
|
|
||||||
try {
|
|
||||||
const granted = await PermissionsAndroid.request(
|
|
||||||
PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE,
|
|
||||||
);
|
|
||||||
return granted === PermissionsAndroid.RESULTS.GRANTED;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function discoverNewFiles(
|
|
||||||
db: ReturnType<typeof useSQLiteContext>,
|
|
||||||
watchPath: string,
|
|
||||||
): Promise<string[]> {
|
|
||||||
const ok = await requestStoragePermission();
|
|
||||||
if (!ok) return [];
|
|
||||||
|
|
||||||
// Normalize: strip trailing slash, then use file:// URI for expo-fs
|
|
||||||
const dir = watchPath.replace(/\/+$/, '');
|
|
||||||
const uri = dir.startsWith('file://') ? dir : `file://${dir}`;
|
|
||||||
|
|
||||||
let entries: string[];
|
|
||||||
try {
|
|
||||||
entries = await FileSystem.readDirectoryAsync(uri);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const newFiles: string[] = [];
|
|
||||||
for (const entry of entries) {
|
|
||||||
const lower = entry.toLowerCase();
|
|
||||||
if (!lower.endsWith('.fit')) continue;
|
|
||||||
const fullPath = `${dir}/${entry}`;
|
|
||||||
if (!isSourcePathImported(db, fullPath)) {
|
|
||||||
newFiles.push(fullPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return newFiles;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Module-level helpers ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function getInstanceUrl(db: ReturnType<typeof useSQLiteContext>): Promise<string> {
|
|
||||||
const row = db.getFirstSync<{ value: string }>(
|
|
||||||
'SELECT value FROM settings WHERE key = ?',
|
|
||||||
['instance_url'],
|
|
||||||
);
|
|
||||||
return (row?.value ?? '').replace(/\/$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
// In-memory cache so repeated imports in one session don't re-download the wheel.
|
|
||||||
let _cachedWheel: { base64: string; filename: string } | null = null;
|
|
||||||
|
|
||||||
async function fetchWheelBase64(instanceUrl: string): Promise<{ base64: string; filename: string }> {
|
|
||||||
if (_cachedWheel) return _cachedWheel;
|
|
||||||
|
|
||||||
const base = instanceUrl || 'https://bincio.org';
|
|
||||||
|
|
||||||
// Ask the instance for the canonical wheel URL (handles both dev and prod layouts).
|
|
||||||
let wheelUrl = `${base}/api/wheel/download`;
|
|
||||||
let wheelFilename = 'bincio-0.1.0-py3-none-any.whl';
|
|
||||||
try {
|
|
||||||
const vr = await fetch(`${base}/api/wheel/version`, { signal: AbortSignal.timeout(5000) });
|
|
||||||
if (vr.ok) {
|
|
||||||
const d = await vr.json() as { api_url?: string; url?: string };
|
|
||||||
const path = d.api_url ?? d.url ?? '/api/wheel/download';
|
|
||||||
wheelUrl = path.startsWith('http') ? path : `${base}${path}`;
|
|
||||||
// Extract the filename from the URL path (last segment after final /)
|
|
||||||
const urlBasename = wheelUrl.split('/').pop() ?? '';
|
|
||||||
if (urlBasename.endsWith('.whl')) wheelFilename = urlBasename;
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
// Fetch via React Native networking (supports local HTTP; WKWebView would block it).
|
|
||||||
const resp = await fetch(wheelUrl);
|
|
||||||
if (!resp.ok) throw new Error(`Could not download Bincio engine (${resp.status}). Is the instance running?`);
|
|
||||||
const buf = await resp.arrayBuffer();
|
|
||||||
_cachedWheel = { base64: arrayBufferToBase64(buf), filename: wheelFilename };
|
|
||||||
return _cachedWheel;
|
|
||||||
}
|
|
||||||
|
|
||||||
function arrayBufferToBase64(buf: ArrayBuffer): string {
|
|
||||||
const bytes = new Uint8Array(buf);
|
|
||||||
let binary = '';
|
|
||||||
// Process in chunks to avoid spread-operator stack overflow on large arrays.
|
|
||||||
const CHUNK = 8192;
|
|
||||||
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
||||||
binary += String.fromCharCode(...(bytes.subarray(i, i + CHUNK) as unknown as number[]));
|
|
||||||
}
|
|
||||||
return btoa(binary);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Styles ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
screen: { flex: 1, backgroundColor: '#09090b' },
|
|
||||||
hiddenEngine: { position: 'absolute', width: 1, height: 1, overflow: 'hidden' },
|
|
||||||
container: { flex: 1 },
|
|
||||||
content: { padding: 16, paddingTop: 60, paddingBottom: 40 },
|
|
||||||
header: { color: '#fff', fontSize: 22, fontWeight: '700', marginBottom: 12 },
|
|
||||||
body: { color: '#a1a1aa', fontSize: 14, lineHeight: 20, marginBottom: 24 },
|
|
||||||
code: { color: '#60a5fa', fontFamily: 'monospace' },
|
|
||||||
serverNotice: {
|
|
||||||
backgroundColor: '#1c1400', borderRadius: 8, borderWidth: 1,
|
|
||||||
borderColor: '#854d0e', padding: 12, marginBottom: 16,
|
|
||||||
},
|
|
||||||
serverNoticeText: { color: '#fbbf24', fontSize: 13, lineHeight: 18 },
|
|
||||||
watchBox: {
|
|
||||||
backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1,
|
|
||||||
borderColor: '#27272a', padding: 14, marginBottom: 16, gap: 10,
|
|
||||||
},
|
|
||||||
watchLabel: { color: '#71717a', fontSize: 11, fontWeight: '600', letterSpacing: 0.5 },
|
|
||||||
watchPath: { color: '#a1a1aa', fontSize: 13, fontFamily: 'monospace' },
|
|
||||||
scanButton: {
|
|
||||||
backgroundColor: '#16a34a', borderRadius: 10,
|
|
||||||
paddingVertical: 14, alignItems: 'center',
|
|
||||||
},
|
|
||||||
button: {
|
|
||||||
backgroundColor: '#2563eb', borderRadius: 10,
|
|
||||||
paddingVertical: 14, alignItems: 'center', marginBottom: 16,
|
|
||||||
},
|
|
||||||
buttonDisabled: { opacity: 0.5 },
|
|
||||||
buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
|
|
||||||
statusBox: {
|
|
||||||
backgroundColor: '#18181b', borderRadius: 8, borderWidth: 1,
|
|
||||||
borderColor: '#27272a', padding: 14, marginBottom: 16, gap: 6,
|
|
||||||
},
|
|
||||||
statusCounter: { color: '#71717a', fontSize: 12, textAlign: 'center' },
|
|
||||||
statusMsg: { color: '#60a5fa', fontSize: 14, textAlign: 'center' },
|
|
||||||
statusHint: { color: '#52525b', fontSize: 12, textAlign: 'center', lineHeight: 16 },
|
|
||||||
success: {
|
|
||||||
backgroundColor: '#14532d', borderRadius: 8, padding: 12, marginBottom: 16, gap: 6,
|
|
||||||
},
|
|
||||||
successEmpty: { backgroundColor: '#1c1c1e' },
|
|
||||||
successText: { color: '#86efac', fontSize: 14 },
|
|
||||||
batchError: { color: '#fca5a5', fontSize: 12 },
|
|
||||||
error: {
|
|
||||||
backgroundColor: '#450a0a', borderRadius: 8, padding: 12, marginBottom: 16, gap: 8,
|
|
||||||
},
|
|
||||||
errorText: { color: '#fca5a5', fontSize: 14 },
|
|
||||||
errorRetry: { color: '#71717a', fontSize: 13, textDecorationLine: 'underline', marginTop: 4 },
|
|
||||||
divider: { height: 1, backgroundColor: '#27272a', marginVertical: 24 },
|
|
||||||
sectionTitle: { color: '#a1a1aa', fontSize: 12, fontWeight: '600', marginBottom: 12, letterSpacing: 0.5 },
|
|
||||||
formatRow: { flexDirection: 'row', gap: 12, marginBottom: 10 },
|
|
||||||
formatName: { color: '#f4f4f5', fontSize: 13, fontWeight: '600', width: 72 },
|
|
||||||
formatDesc: { color: '#71717a', fontSize: 13, flex: 1 },
|
|
||||||
notice: {
|
|
||||||
marginTop: 8, backgroundColor: '#18181b',
|
|
||||||
borderRadius: 8, padding: 12, borderWidth: 1, borderColor: '#27272a',
|
|
||||||
},
|
|
||||||
noticeText: { color: '#71717a', fontSize: 12, lineHeight: 18 },
|
|
||||||
noticeCode: { fontFamily: 'monospace', color: '#a1a1aa' },
|
|
||||||
});
|
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
import * as FileSystem from 'expo-file-system';
|
|
||||||
import { useFocusEffect } from 'expo-router';
|
|
||||||
import { useSQLiteContext } from 'expo-sqlite';
|
|
||||||
import { useCallback, useState } from 'react';
|
|
||||||
import { Alert, FlatList, Pressable, RefreshControl, StyleSheet, Text, TextInput, View } from 'react-native';
|
|
||||||
import { deleteActivities, useActivities, useActivityCount, PAGE_SIZE } from '@/db/queries';
|
|
||||||
import { downloadFeed, uploadFeed } from '@/db/sync';
|
|
||||||
import { useTheme } from '@/ThemeContext';
|
|
||||||
import { ActivityCard } from '@/components/ActivityCard';
|
|
||||||
|
|
||||||
export default function FeedScreen() {
|
|
||||||
const db = useSQLiteContext();
|
|
||||||
const theme = useTheme();
|
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [limit, setLimit] = useState(PAGE_SIZE);
|
|
||||||
const activities = useActivities(searchQuery, limit);
|
|
||||||
const totalCount = useActivityCount(searchQuery);
|
|
||||||
const hasMore = activities.length < totalCount;
|
|
||||||
const [downloading, setDownloading] = useState(false);
|
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const [statusMsg, setStatusMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
||||||
const selecting = selected.size > 0;
|
|
||||||
|
|
||||||
// Auto-refresh the local list whenever the tab comes into focus.
|
|
||||||
// SQLite getAllSync is sub-millisecond — no network, no lag.
|
|
||||||
useFocusEffect(useCallback(() => {
|
|
||||||
setRefreshKey(k => k + 1);
|
|
||||||
}, []));
|
|
||||||
|
|
||||||
function showMsg(ok: boolean, text: string) {
|
|
||||||
setStatusMsg({ ok, text });
|
|
||||||
setTimeout(() => setStatusMsg(null), 3500);
|
|
||||||
}
|
|
||||||
|
|
||||||
const doDownload = useCallback(async () => {
|
|
||||||
setDownloading(true);
|
|
||||||
setStatusMsg(null);
|
|
||||||
const result = await downloadFeed(db);
|
|
||||||
setDownloading(false);
|
|
||||||
setRefreshKey(k => k + 1);
|
|
||||||
if (result.error) {
|
|
||||||
showMsg(false, result.error);
|
|
||||||
} else if (result.total === 0) {
|
|
||||||
showMsg(true, 'No activities on instance');
|
|
||||||
} else if (result.synced === 0 && !result.fetched) {
|
|
||||||
showMsg(true, `Up to date (${result.total} activities)`);
|
|
||||||
} else {
|
|
||||||
const parts = [];
|
|
||||||
if (result.synced > 0) parts.push(`${result.synced} new`);
|
|
||||||
if (result.fetched) parts.push(`${result.fetched} full dataset${result.fetched === 1 ? '' : 's'}`);
|
|
||||||
showMsg(true, `Downloaded: ${parts.join(', ')} (${result.total} total)`);
|
|
||||||
}
|
|
||||||
}, [db]);
|
|
||||||
|
|
||||||
const doUpload = useCallback(async () => {
|
|
||||||
setUploading(true);
|
|
||||||
setStatusMsg(null);
|
|
||||||
const result = await uploadFeed(db, (n, total) => {
|
|
||||||
setStatusMsg({ ok: true, text: `Uploading ${n} / ${total}…` });
|
|
||||||
});
|
|
||||||
setUploading(false);
|
|
||||||
if (result.error) {
|
|
||||||
showMsg(false, result.error);
|
|
||||||
} else if (!result.uploaded && !result.failed) {
|
|
||||||
showMsg(true, 'Nothing to upload');
|
|
||||||
} else {
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (result.uploaded) parts.push(`${result.uploaded} uploaded`);
|
|
||||||
if (result.failed) parts.push(`${result.failed} failed`);
|
|
||||||
showMsg(result.failed ? false : true, parts.join(', '));
|
|
||||||
}
|
|
||||||
}, [db]);
|
|
||||||
|
|
||||||
function doRefresh() {
|
|
||||||
setRefreshKey(k => k + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSearch(q: string) {
|
|
||||||
setSearchQuery(q);
|
|
||||||
setLimit(PAGE_SIZE); // reset pagination when search changes
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadMore() {
|
|
||||||
if (hasMore) setLimit(l => l + PAGE_SIZE);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSelect(id: string) {
|
|
||||||
setSelected(prev => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(id)) next.delete(id); else next.add(id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelSelect() { setSelected(new Set()); }
|
|
||||||
|
|
||||||
function confirmDeleteSelected() {
|
|
||||||
const count = selected.size;
|
|
||||||
Alert.alert(
|
|
||||||
`Delete ${count} activit${count === 1 ? 'y' : 'ies'}`,
|
|
||||||
'These activities will be permanently removed from your device.',
|
|
||||||
[
|
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
|
||||||
{
|
|
||||||
text: 'Delete',
|
|
||||||
style: 'destructive',
|
|
||||||
onPress: async () => {
|
|
||||||
const ids = Array.from(selected);
|
|
||||||
const paths = await deleteActivities(db, ids);
|
|
||||||
setSelected(new Set());
|
|
||||||
for (const p of paths) {
|
|
||||||
if (p) try { await FileSystem.deleteAsync(p, { idempotent: true }); } catch {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const busy = downloading || uploading;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<View style={styles.headerRow}>
|
|
||||||
{selecting ? (
|
|
||||||
<>
|
|
||||||
<Text style={styles.header}>{selected.size} selected</Text>
|
|
||||||
<Pressable style={styles.cancelButton} onPress={cancelSelect}>
|
|
||||||
<Text style={styles.cancelText}>Cancel</Text>
|
|
||||||
</Pressable>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Text style={styles.header}>Feed</Text>
|
|
||||||
<View style={styles.actionButtons}>
|
|
||||||
<ActionButton
|
|
||||||
icon="↑"
|
|
||||||
label="Upload"
|
|
||||||
loading={uploading}
|
|
||||||
disabled={busy}
|
|
||||||
accent={theme.accent}
|
|
||||||
dim={theme.dim}
|
|
||||||
onPress={doUpload}
|
|
||||||
/>
|
|
||||||
<ActionButton
|
|
||||||
icon="↓"
|
|
||||||
label="Download"
|
|
||||||
loading={downloading}
|
|
||||||
disabled={busy}
|
|
||||||
accent={theme.accent}
|
|
||||||
dim={theme.dim}
|
|
||||||
onPress={doDownload}
|
|
||||||
/>
|
|
||||||
<ActionButton
|
|
||||||
icon="↺"
|
|
||||||
label="Refresh"
|
|
||||||
loading={false}
|
|
||||||
disabled={busy}
|
|
||||||
accent={theme.accent}
|
|
||||||
dim={theme.dim}
|
|
||||||
onPress={doRefresh}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{statusMsg && (
|
|
||||||
<Text style={statusMsg.ok ? styles.msgOk : styles.msgErr}>{statusMsg.text}</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!selecting && (
|
|
||||||
<View style={styles.searchRow}>
|
|
||||||
<TextInput
|
|
||||||
style={styles.searchInput}
|
|
||||||
value={searchQuery}
|
|
||||||
onChangeText={handleSearch}
|
|
||||||
placeholder="Search activities…"
|
|
||||||
placeholderTextColor="#52525b"
|
|
||||||
returnKeyType="search"
|
|
||||||
clearButtonMode="while-editing"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activities.length === 0 && !busy ? (
|
|
||||||
<View style={styles.empty}>
|
|
||||||
<Text style={styles.emptyIcon}>🚴</Text>
|
|
||||||
<Text style={styles.emptyTitle}>No activities yet</Text>
|
|
||||||
<Text style={styles.emptyBody}>
|
|
||||||
Import a file or tap ↓ to pull from your instance.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<FlatList
|
|
||||||
data={activities}
|
|
||||||
keyExtractor={(a) => a.id}
|
|
||||||
extraData={refreshKey}
|
|
||||||
renderItem={({ item }) => (
|
|
||||||
<ActivityCard
|
|
||||||
activity={item}
|
|
||||||
selecting={selecting}
|
|
||||||
checked={selected.has(item.id)}
|
|
||||||
onToggleSelect={() => toggleSelect(item.id)}
|
|
||||||
onLongPress={() => toggleSelect(item.id)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
contentContainerStyle={styles.list}
|
|
||||||
onEndReached={loadMore}
|
|
||||||
onEndReachedThreshold={0.3}
|
|
||||||
refreshControl={
|
|
||||||
<RefreshControl
|
|
||||||
refreshing={false}
|
|
||||||
onRefresh={doRefresh}
|
|
||||||
tintColor="#60a5fa"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selecting && (
|
|
||||||
<View style={styles.actionBar}>
|
|
||||||
<Pressable style={styles.deleteBarButton} onPress={confirmDeleteSelected}>
|
|
||||||
<Text style={styles.deleteBarText}>Delete {selected.size}</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActionButton({
|
|
||||||
icon, label, loading, disabled, accent, dim, onPress,
|
|
||||||
}: {
|
|
||||||
icon: string;
|
|
||||||
label: string;
|
|
||||||
loading: boolean;
|
|
||||||
disabled: boolean;
|
|
||||||
accent: string;
|
|
||||||
dim: string;
|
|
||||||
onPress: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
style={[styles.actionBtn, { backgroundColor: dim }, disabled && styles.actionBtnDisabled]}
|
|
||||||
onPress={disabled ? undefined : onPress}
|
|
||||||
accessibilityLabel={label}
|
|
||||||
>
|
|
||||||
<Text style={[styles.actionBtnIcon, { color: loading ? '#52525b' : accent }]}>
|
|
||||||
{loading ? '…' : icon}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: { flex: 1, backgroundColor: '#09090b' },
|
|
||||||
headerRow: {
|
|
||||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
|
||||||
paddingHorizontal: 16, paddingTop: 60, paddingBottom: 12,
|
|
||||||
},
|
|
||||||
header: { color: '#fff', fontSize: 22, fontWeight: '700' },
|
|
||||||
actionButtons: { flexDirection: 'row', gap: 8 },
|
|
||||||
actionBtn: {
|
|
||||||
width: 36, height: 36, borderRadius: 8,
|
|
||||||
alignItems: 'center', justifyContent: 'center',
|
|
||||||
},
|
|
||||||
actionBtnDisabled: { opacity: 0.4 },
|
|
||||||
actionBtnIcon: { fontSize: 18, fontWeight: '700', lineHeight: 22 },
|
|
||||||
cancelButton: {
|
|
||||||
backgroundColor: '#27272a', borderRadius: 8,
|
|
||||||
paddingHorizontal: 14, paddingVertical: 7,
|
|
||||||
},
|
|
||||||
cancelText: { color: '#a1a1aa', fontSize: 13, fontWeight: '600' },
|
|
||||||
msgOk: { color: '#86efac', fontSize: 12, textAlign: 'center', paddingHorizontal: 16, paddingBottom: 8 },
|
|
||||||
msgErr: { color: '#fca5a5', fontSize: 12, textAlign: 'center', paddingHorizontal: 16, paddingBottom: 8 },
|
|
||||||
searchRow: { paddingHorizontal: 16, paddingBottom: 10 },
|
|
||||||
searchInput: {
|
|
||||||
backgroundColor: '#18181b', borderWidth: 1, borderColor: '#27272a',
|
|
||||||
borderRadius: 8, paddingHorizontal: 12, paddingVertical: 8,
|
|
||||||
color: '#f4f4f5', fontSize: 14,
|
|
||||||
},
|
|
||||||
list: { padding: 16, gap: 12, paddingBottom: 80 },
|
|
||||||
empty: {
|
|
||||||
flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32,
|
|
||||||
},
|
|
||||||
emptyIcon: { fontSize: 48, marginBottom: 16 },
|
|
||||||
emptyTitle: { color: '#f4f4f5', fontSize: 18, fontWeight: '600', marginBottom: 8 },
|
|
||||||
emptyBody: { color: '#71717a', fontSize: 14, textAlign: 'center', lineHeight: 20 },
|
|
||||||
actionBar: {
|
|
||||||
position: 'absolute', bottom: 0, left: 0, right: 0,
|
|
||||||
backgroundColor: '#18181b', borderTopWidth: 1, borderTopColor: '#27272a',
|
|
||||||
paddingHorizontal: 16, paddingVertical: 12, paddingBottom: 28,
|
|
||||||
},
|
|
||||||
deleteBarButton: {
|
|
||||||
backgroundColor: '#7f1d1d', borderRadius: 10,
|
|
||||||
paddingVertical: 14, alignItems: 'center',
|
|
||||||
},
|
|
||||||
deleteBarText: { color: '#fca5a5', fontSize: 15, fontWeight: '700' },
|
|
||||||
});
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { FlatList, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
|
||||||
import { PAGE_SIZE, useActivityYears, useFilteredActivities, useFilteredCount, type ActivityFilter } from '@/db/queries';
|
|
||||||
import { ActivityCard } from '@/components/ActivityCard';
|
|
||||||
import { useTheme } from '@/ThemeContext';
|
|
||||||
|
|
||||||
type SortKey = 'date' | 'distance' | 'elevation';
|
|
||||||
|
|
||||||
const SPORTS = [
|
|
||||||
{ value: '', label: 'All' },
|
|
||||||
{ value: 'cycling', label: '🚴 Cycling' },
|
|
||||||
{ value: 'running', label: '🏃 Running' },
|
|
||||||
{ value: 'hiking', label: '🥾 Hiking' },
|
|
||||||
{ value: 'swimming', label: '🏊 Swimming' },
|
|
||||||
{ value: 'walking', label: '🚶 Walking' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const DATE_PRESETS = [
|
|
||||||
{ value: 'all', label: 'All time' },
|
|
||||||
{ value: '7d', label: '7 days' },
|
|
||||||
{ value: '30d', label: '30 days' },
|
|
||||||
{ value: '6mo', label: '6 months' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SORTS: { value: SortKey; label: string }[] = [
|
|
||||||
{ value: 'date', label: 'Newest' },
|
|
||||||
{ value: 'distance', label: 'Distance' },
|
|
||||||
{ value: 'elevation', label: 'Elevation' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function computeDateRange(preset: string): { dateFrom: string; dateTo: string } {
|
|
||||||
if (preset === 'all') return { dateFrom: '', dateTo: '' };
|
|
||||||
if (/^\d{4}$/.test(preset)) {
|
|
||||||
const y = parseInt(preset, 10);
|
|
||||||
return { dateFrom: `${y}-01-01T000000Z`, dateTo: `${y + 1}-01-01T000000Z` };
|
|
||||||
}
|
|
||||||
const pad = (n: number) => String(n).padStart(2, '0');
|
|
||||||
const now = new Date();
|
|
||||||
let d: Date;
|
|
||||||
if (preset === '7d') d = new Date(now.getTime() - 7 * 86_400_000);
|
|
||||||
else if (preset === '30d') d = new Date(now.getTime() - 30 * 86_400_000);
|
|
||||||
else { d = new Date(now); d.setMonth(d.getMonth() - 6); }
|
|
||||||
return { dateFrom: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T000000Z`, dateTo: '' };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SearchScreen() {
|
|
||||||
const theme = useTheme();
|
|
||||||
const [sport, setSport] = useState('');
|
|
||||||
const [datePre, setDatePre] = useState('all');
|
|
||||||
const [sort, setSort] = useState<SortKey>('date');
|
|
||||||
const [limit, setLimit] = useState(PAGE_SIZE);
|
|
||||||
|
|
||||||
const years = useActivityYears();
|
|
||||||
const dateOptions = [...DATE_PRESETS, ...years.map(y => ({ value: y, label: y }))];
|
|
||||||
|
|
||||||
const { dateFrom, dateTo } = computeDateRange(datePre);
|
|
||||||
const filter: ActivityFilter = { sport, dateFrom, dateTo, sort };
|
|
||||||
const activities = useFilteredActivities(filter, limit);
|
|
||||||
const total = useFilteredCount(filter);
|
|
||||||
const hasMore = activities.length < total;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<View style={styles.headerRow}>
|
|
||||||
<Text style={styles.header}>Filter</Text>
|
|
||||||
{total > 0 && <Text style={styles.count}>{total} activities</Text>}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}
|
|
||||||
style={styles.pillScroll} contentContainerStyle={styles.pillRow}>
|
|
||||||
{SPORTS.map(s => (
|
|
||||||
<Pill key={s.value} label={s.label} active={sport === s.value} accent={theme.accent}
|
|
||||||
onPress={() => { setSport(s.value); setLimit(PAGE_SIZE); }} />
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}
|
|
||||||
style={styles.pillScroll} contentContainerStyle={styles.pillRow}>
|
|
||||||
{dateOptions.map(d => (
|
|
||||||
<Pill key={d.value} label={d.label} active={datePre === d.value} accent={theme.accent}
|
|
||||||
onPress={() => { setDatePre(d.value); setLimit(PAGE_SIZE); }} />
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
<View style={styles.sortRow}>
|
|
||||||
{SORTS.map(s => (
|
|
||||||
<Pressable key={s.value}
|
|
||||||
style={[styles.sortBtn, sort === s.value && { borderBottomColor: theme.accent, borderBottomWidth: 2 }]}
|
|
||||||
onPress={() => { setSort(s.value); setLimit(PAGE_SIZE); }}>
|
|
||||||
<Text style={[styles.sortText, sort === s.value && { color: theme.accent }]}>{s.label}</Text>
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{activities.length === 0 ? (
|
|
||||||
<View style={styles.empty}>
|
|
||||||
<Text style={styles.emptyText}>No activities match</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<FlatList
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
data={activities}
|
|
||||||
keyExtractor={a => a.id}
|
|
||||||
renderItem={({ item }) => (
|
|
||||||
<ActivityCard activity={item} selecting={false} checked={false}
|
|
||||||
onToggleSelect={() => {}} onLongPress={() => {}} />
|
|
||||||
)}
|
|
||||||
contentContainerStyle={styles.list}
|
|
||||||
onEndReached={() => { if (hasMore) setLimit(l => l + PAGE_SIZE); }}
|
|
||||||
onEndReachedThreshold={0.3}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Pill({ label, active, accent, onPress }: {
|
|
||||||
label: string; active: boolean; accent: string; onPress: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
style={[styles.pill, active && { backgroundColor: accent + '33', borderColor: accent }]}
|
|
||||||
onPress={onPress}
|
|
||||||
>
|
|
||||||
<Text style={[styles.pillText, active && { color: accent }]}>{label}</Text>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: { flex: 1, backgroundColor: '#09090b' },
|
|
||||||
headerRow: {
|
|
||||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
|
||||||
paddingHorizontal: 16, paddingTop: 60, paddingBottom: 12,
|
|
||||||
},
|
|
||||||
header: { color: '#fff', fontSize: 22, fontWeight: '700' },
|
|
||||||
count: { color: '#71717a', fontSize: 13 },
|
|
||||||
pillScroll: { flexGrow: 0, flexShrink: 0 },
|
|
||||||
pillRow: { flexDirection: 'row', gap: 8, paddingHorizontal: 16, paddingBottom: 10 },
|
|
||||||
pill: {
|
|
||||||
borderRadius: 20, borderWidth: 1, borderColor: '#3f3f46',
|
|
||||||
paddingHorizontal: 14, paddingVertical: 7,
|
|
||||||
},
|
|
||||||
pillText: { color: '#a1a1aa', fontSize: 13, fontWeight: '500' },
|
|
||||||
sortRow: { flexDirection: 'row', paddingHorizontal: 16, marginBottom: 4 },
|
|
||||||
sortBtn: { marginRight: 24, paddingBottom: 8, borderBottomWidth: 2, borderBottomColor: 'transparent' },
|
|
||||||
sortText: { color: '#71717a', fontSize: 13, fontWeight: '600' },
|
|
||||||
list: { padding: 16, gap: 12, paddingBottom: 80 },
|
|
||||||
empty: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
|
||||||
emptyText: { color: '#52525b', fontSize: 15 },
|
|
||||||
});
|
|
||||||
@@ -1,388 +0,0 @@
|
|||||||
import { useSQLiteContext } from 'expo-sqlite';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import {
|
|
||||||
ActivityIndicator, Platform, Pressable, ScrollView, StyleSheet,
|
|
||||||
Text, TextInput, View,
|
|
||||||
} from 'react-native';
|
|
||||||
import { deleteRemoteActivities, getSetting, setSetting, useSetting } from '@/db/queries';
|
|
||||||
import { PALETTES, type PaletteKey } from '@/theme';
|
|
||||||
import { useTheme, usePaletteControl } from '@/ThemeContext';
|
|
||||||
|
|
||||||
export default function SettingsScreen() {
|
|
||||||
const db = useSQLiteContext();
|
|
||||||
|
|
||||||
const storedUrl = useSetting('instance_url') ?? '';
|
|
||||||
const storedHandle = useSetting('handle') ?? '';
|
|
||||||
const storedPath = useSetting('auto_import_path') ?? '';
|
|
||||||
const storedToken = useSetting('api_token');
|
|
||||||
const storedSyncMode = (useSetting('sync_mode') ?? 'summaries') as 'summaries' | 'full';
|
|
||||||
const storedSyncUpload = useSetting('sync_upload') === 'true';
|
|
||||||
const storedUploadFormat = (useSetting('upload_format') ?? 'raw') as 'raw' | 'bas';
|
|
||||||
|
|
||||||
const [instanceUrl, setInstanceUrl] = useState(storedUrl);
|
|
||||||
const [handle, setHandle] = useState(storedHandle);
|
|
||||||
const [autoPath, setAutoPath] = useState(storedPath);
|
|
||||||
const [syncMode, setSyncMode] = useState(storedSyncMode);
|
|
||||||
const [syncUpload, setSyncUpload] = useState(storedSyncUpload);
|
|
||||||
const [uploadFormat, setUploadFormat] = useState(storedUploadFormat);
|
|
||||||
const [saved, setSaved] = useState(false);
|
|
||||||
const theme = useTheme();
|
|
||||||
const { paletteKey: palette, setPaletteOverride } = usePaletteControl();
|
|
||||||
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [connecting, setConnecting] = useState(false);
|
|
||||||
const [connectMsg, setConnectMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
|
||||||
|
|
||||||
const [resetArmed, setResetArmed] = useState(false);
|
|
||||||
const [resetMsg, setResetMsg] = useState<string | null>(null);
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
await setSetting(db, 'instance_url', instanceUrl.trim());
|
|
||||||
await setSetting(db, 'handle', handle.trim());
|
|
||||||
setSaved(true);
|
|
||||||
setTimeout(() => setSaved(false), 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function connect() {
|
|
||||||
const url = instanceUrl.trim().replace(/\/$/, '');
|
|
||||||
const h = handle.trim();
|
|
||||||
if (!url || !h || !password) {
|
|
||||||
setConnectMsg({ ok: false, text: 'Fill in URL, handle, and password first.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setConnecting(true);
|
|
||||||
setConnectMsg(null);
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`${url}/api/auth/token`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ handle: h, password }),
|
|
||||||
});
|
|
||||||
if (!resp.ok) {
|
|
||||||
const err = await resp.json().catch(() => ({}));
|
|
||||||
setConnectMsg({ ok: false, text: err.detail ?? `Error ${resp.status}` });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await resp.json();
|
|
||||||
await setSetting(db, 'instance_url', url);
|
|
||||||
await setSetting(db, 'handle', h);
|
|
||||||
await setSetting(db, 'api_token', data.token);
|
|
||||||
setPassword('');
|
|
||||||
setConnectMsg({ ok: true, text: `Connected as ${data.display_name || h}` });
|
|
||||||
} catch {
|
|
||||||
setConnectMsg({ ok: false, text: 'Could not reach instance — check the URL.' });
|
|
||||||
} finally {
|
|
||||||
setConnecting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function disconnect() {
|
|
||||||
await setSetting(db, 'api_token', '');
|
|
||||||
setConnectMsg(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resetSyncedData() {
|
|
||||||
if (!resetArmed) {
|
|
||||||
setResetArmed(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const n = await deleteRemoteActivities(db);
|
|
||||||
setResetArmed(false);
|
|
||||||
setResetMsg(`Removed ${n} synced ${n === 1 ? 'activity' : 'activities'}`);
|
|
||||||
setTimeout(() => setResetMsg(null), 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isConnected = !!storedToken;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
|
||||||
<Text style={styles.header}>Settings</Text>
|
|
||||||
|
|
||||||
<Section title="Instance">
|
|
||||||
<Field
|
|
||||||
label="Instance URL"
|
|
||||||
placeholder="https://bincio.org"
|
|
||||||
value={instanceUrl}
|
|
||||||
onChangeText={setInstanceUrl}
|
|
||||||
autoCapitalize="none"
|
|
||||||
keyboardType="url"
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Handle"
|
|
||||||
placeholder="yourhandle"
|
|
||||||
value={handle}
|
|
||||||
onChangeText={setHandle}
|
|
||||||
autoCapitalize="none"
|
|
||||||
/>
|
|
||||||
<Text style={styles.hint}>
|
|
||||||
Connect to a Bincio instance to sync your activities. Leave blank to use
|
|
||||||
the app offline only.
|
|
||||||
</Text>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Pressable style={styles.saveButton} onPress={save}>
|
|
||||||
<Text style={styles.saveButtonText}>
|
|
||||||
{saved ? '✓ Saved' : 'Save'}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<Section title="Connection">
|
|
||||||
{isConnected ? (
|
|
||||||
<>
|
|
||||||
<Row label="Status" value={`Connected as ${storedHandle || '—'}`} />
|
|
||||||
<Pressable style={styles.disconnectButton} onPress={disconnect}>
|
|
||||||
<Text style={styles.disconnectText}>Disconnect</Text>
|
|
||||||
</Pressable>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Field
|
|
||||||
label="Password"
|
|
||||||
placeholder="••••••••"
|
|
||||||
value={password}
|
|
||||||
onChangeText={setPassword}
|
|
||||||
autoCapitalize="none"
|
|
||||||
secureTextEntry
|
|
||||||
/>
|
|
||||||
<Pressable
|
|
||||||
style={[styles.connectButton, connecting && styles.buttonDisabled]}
|
|
||||||
onPress={connecting ? undefined : connect}
|
|
||||||
>
|
|
||||||
{connecting
|
|
||||||
? <ActivityIndicator color="#fff" size="small" />
|
|
||||||
: <Text style={styles.connectText}>Connect</Text>}
|
|
||||||
</Pressable>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{connectMsg && (
|
|
||||||
<Text style={connectMsg.ok ? styles.msgOk : styles.msgErr}>
|
|
||||||
{connectMsg.text}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<Text style={styles.hint}>
|
|
||||||
Your password is used once to obtain a session token, then forgotten.
|
|
||||||
The token is stored locally and sent with each sync request.
|
|
||||||
</Text>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{Platform.OS === 'android' && (
|
|
||||||
<Section title="Auto-import (Android)">
|
|
||||||
{!storedUrl ? (
|
|
||||||
<Text style={[styles.hint, styles.hintWarn]}>
|
|
||||||
Configure and save a Bincio instance URL above first — it's needed to download the extraction engine.
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Field
|
|
||||||
label="Watch directory"
|
|
||||||
placeholder="/sdcard/FitFiles"
|
|
||||||
value={autoPath}
|
|
||||||
onChangeText={setAutoPath}
|
|
||||||
onBlur={() => setSetting(db, 'auto_import_path', autoPath.trim())}
|
|
||||||
autoCapitalize="none"
|
|
||||||
/>
|
|
||||||
<Text style={styles.hint}>
|
|
||||||
New FIT files in this folder are imported automatically when you
|
|
||||||
open the app. Leave blank to disable. Requires storage permission.
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Section title="Sync">
|
|
||||||
<Text style={styles.subLabel}>Download</Text>
|
|
||||||
<View style={styles.modeRow}>
|
|
||||||
<ModeButton label="Summaries only" active={syncMode === 'summaries'} accent={theme.accent} dim={theme.dim}
|
|
||||||
onPress={() => { setSyncMode('summaries'); setSetting(db, 'sync_mode', 'summaries'); }} />
|
|
||||||
<ModeButton label="Full data" active={syncMode === 'full'} accent={theme.accent} dim={theme.dim}
|
|
||||||
onPress={() => { setSyncMode('full'); setSetting(db, 'sync_mode', 'full'); }} />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.hint}>
|
|
||||||
{syncMode === 'full'
|
|
||||||
? 'Downloads map route and elevation chart for every activity during sync. Uses more storage and takes longer.'
|
|
||||||
: 'Syncs activity summaries only. Map and chart are fetched on demand when you open an activity.'}
|
|
||||||
</Text>
|
|
||||||
<Text style={[styles.subLabel, { borderTopWidth: 1, borderTopColor: '#27272a', paddingTop: 12 }]}>Upload</Text>
|
|
||||||
<View style={styles.modeRow}>
|
|
||||||
<ModeButton label="Off" active={!syncUpload} accent={theme.accent} dim={theme.dim}
|
|
||||||
onPress={() => { setSyncUpload(false); setSetting(db, 'sync_upload', 'false'); }} />
|
|
||||||
<ModeButton label="Upload local activities" active={syncUpload} accent={theme.accent} dim={theme.dim}
|
|
||||||
onPress={() => { setSyncUpload(true); setSetting(db, 'sync_upload', 'true'); }} />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.hint}>
|
|
||||||
{syncUpload
|
|
||||||
? 'Local activities are uploaded to the instance during sync.'
|
|
||||||
: 'Local activities stay on device only.'}
|
|
||||||
</Text>
|
|
||||||
<Text style={[styles.subLabel, { borderTopWidth: 1, borderTopColor: '#27272a', paddingTop: 12 }]}>Upload format</Text>
|
|
||||||
<View style={styles.modeRow}>
|
|
||||||
<ModeButton label="Original file" active={uploadFormat === 'raw'} accent={theme.accent} dim={theme.dim}
|
|
||||||
onPress={() => { setUploadFormat('raw'); setSetting(db, 'upload_format', 'raw'); }} />
|
|
||||||
<ModeButton label="Extracted JSON" active={uploadFormat === 'bas'} accent={theme.accent} dim={theme.dim}
|
|
||||||
onPress={() => { setUploadFormat('bas'); setSetting(db, 'upload_format', 'bas'); }} />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.hint}>
|
|
||||||
{uploadFormat === 'raw'
|
|
||||||
? 'Uploads the original FIT/GPX/TCX file. The server re-extracts it with DEM elevation correction and updates your local copy.'
|
|
||||||
: 'Uploads the pre-extracted JSON. Faster, but no DEM elevation correction.'}
|
|
||||||
</Text>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Palette">
|
|
||||||
<Text style={[styles.hint, { paddingBottom: 0 }]}>
|
|
||||||
Auto-switches to race colours during Giro, Tour, and Vuelta. Override here for testing.
|
|
||||||
</Text>
|
|
||||||
<View style={styles.modeRow}>
|
|
||||||
{(['auto', 'default', 'giro', 'tour', 'vuelta'] as PaletteKey[]).map(key => {
|
|
||||||
const label = key === 'auto' ? 'Auto' : PALETTES[key as keyof typeof PALETTES].label;
|
|
||||||
const keyAccent = key === 'auto' ? theme.accent : PALETTES[key as keyof typeof PALETTES].accent;
|
|
||||||
const keyDim = key === 'auto' ? theme.dim : PALETTES[key as keyof typeof PALETTES].dim;
|
|
||||||
return (
|
|
||||||
<ModeButton
|
|
||||||
key={key}
|
|
||||||
label={label}
|
|
||||||
active={palette === key}
|
|
||||||
accent={keyAccent}
|
|
||||||
dim={keyDim}
|
|
||||||
onPress={() => setPaletteOverride(key)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Data">
|
|
||||||
<Pressable
|
|
||||||
style={[styles.resetButton, resetArmed && styles.resetButtonArmed]}
|
|
||||||
onPress={resetSyncedData}
|
|
||||||
onBlur={() => setResetArmed(false)}
|
|
||||||
>
|
|
||||||
<Text style={[styles.resetText, resetArmed && styles.resetTextArmed]}>
|
|
||||||
{resetArmed ? 'Tap again to confirm' : 'Reset synced data'}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
{resetMsg && <Text style={styles.msgOk}>{resetMsg}</Text>}
|
|
||||||
<Text style={styles.hint}>
|
|
||||||
Removes all activities synced from the instance. Locally imported files are kept.
|
|
||||||
</Text>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="About">
|
|
||||||
<Row label="Version" value="0.1.0 (Phase 0.5)" />
|
|
||||||
<Row label="Schema" value="BAS 1.0" />
|
|
||||||
</Section>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<View style={styles.section}>
|
|
||||||
<Text style={styles.sectionTitle}>{title}</Text>
|
|
||||||
<View style={styles.sectionBody}>{children}</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Field({
|
|
||||||
label, placeholder, value, onChangeText, ...rest
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
placeholder: string;
|
|
||||||
value: string;
|
|
||||||
onChangeText: (v: string) => void;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<View style={styles.field}>
|
|
||||||
<Text style={styles.fieldLabel}>{label}</Text>
|
|
||||||
<TextInput
|
|
||||||
style={styles.input}
|
|
||||||
placeholder={placeholder}
|
|
||||||
placeholderTextColor="#52525b"
|
|
||||||
value={value}
|
|
||||||
onChangeText={onChangeText}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModeButton({ label, active, accent, dim, onPress }: {
|
|
||||||
label: string; active: boolean; accent: string; dim: string; onPress: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
style={[styles.modeButton, active && { backgroundColor: dim, borderColor: accent }]}
|
|
||||||
onPress={onPress}
|
|
||||||
>
|
|
||||||
<Text style={[styles.modeButtonText, active && { color: accent }]}>{label}</Text>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Row({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<View style={styles.row}>
|
|
||||||
<Text style={styles.rowLabel}>{label}</Text>
|
|
||||||
<Text style={styles.rowValue}>{value}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: { flex: 1, backgroundColor: '#09090b' },
|
|
||||||
content: { padding: 16, paddingTop: 60, paddingBottom: 40 },
|
|
||||||
header: { color: '#fff', fontSize: 22, fontWeight: '700', marginBottom: 24 },
|
|
||||||
section: { marginBottom: 28 },
|
|
||||||
sectionTitle: {
|
|
||||||
color: '#a1a1aa', fontSize: 11, fontWeight: '600',
|
|
||||||
letterSpacing: 0.8, marginBottom: 8,
|
|
||||||
},
|
|
||||||
sectionBody: {
|
|
||||||
backgroundColor: '#18181b', borderRadius: 10,
|
|
||||||
borderWidth: 1, borderColor: '#27272a', overflow: 'hidden',
|
|
||||||
},
|
|
||||||
field: { padding: 14, borderBottomWidth: 1, borderBottomColor: '#27272a' },
|
|
||||||
fieldLabel: { color: '#71717a', fontSize: 11, marginBottom: 4 },
|
|
||||||
input: { color: '#f4f4f5', fontSize: 15 },
|
|
||||||
hint: { color: '#52525b', fontSize: 12, lineHeight: 16, padding: 12 },
|
|
||||||
hintWarn: { color: '#a16207' },
|
|
||||||
row: {
|
|
||||||
flexDirection: 'row', justifyContent: 'space-between',
|
|
||||||
paddingHorizontal: 14, paddingVertical: 12,
|
|
||||||
borderBottomWidth: 1, borderBottomColor: '#27272a',
|
|
||||||
},
|
|
||||||
rowLabel: { color: '#a1a1aa', fontSize: 14 },
|
|
||||||
rowValue: { color: '#71717a', fontSize: 14 },
|
|
||||||
saveButton: {
|
|
||||||
backgroundColor: '#2563eb', borderRadius: 10,
|
|
||||||
paddingVertical: 14, alignItems: 'center', marginBottom: 28,
|
|
||||||
},
|
|
||||||
saveButtonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
|
|
||||||
connectButton: {
|
|
||||||
backgroundColor: '#059669', borderRadius: 8, margin: 12,
|
|
||||||
paddingVertical: 12, alignItems: 'center',
|
|
||||||
},
|
|
||||||
connectText: { color: '#fff', fontWeight: '600', fontSize: 15 },
|
|
||||||
buttonDisabled: { opacity: 0.5 },
|
|
||||||
disconnectButton: {
|
|
||||||
margin: 12, paddingVertical: 10, alignItems: 'center',
|
|
||||||
borderRadius: 8, borderWidth: 1, borderColor: '#3f3f46',
|
|
||||||
},
|
|
||||||
disconnectText: { color: '#71717a', fontSize: 14 },
|
|
||||||
msgOk: { color: '#86efac', fontSize: 13, paddingHorizontal: 12, paddingBottom: 10 },
|
|
||||||
msgErr: { color: '#fca5a5', fontSize: 13, paddingHorizontal: 12, paddingBottom: 10 },
|
|
||||||
subLabel: { color: '#52525b', fontSize: 11, fontWeight: '600', letterSpacing: 0.6, paddingHorizontal: 12, paddingTop: 12, paddingBottom: 4 },
|
|
||||||
modeRow: { flexDirection: 'row', gap: 8, padding: 12 },
|
|
||||||
modeButton: { flex: 1, paddingVertical: 9, borderRadius: 8, borderWidth: 1, borderColor: '#3f3f46', alignItems: 'center' },
|
|
||||||
modeButtonText: { color: '#71717a', fontSize: 13, fontWeight: '500' },
|
|
||||||
resetButton: {
|
|
||||||
margin: 12, paddingVertical: 10, alignItems: 'center',
|
|
||||||
borderRadius: 8, borderWidth: 1, borderColor: '#3f3f46',
|
|
||||||
},
|
|
||||||
resetButtonArmed: { borderColor: '#ef4444', backgroundColor: '#1c0a0a' },
|
|
||||||
resetText: { color: '#71717a', fontSize: 14 },
|
|
||||||
resetTextArmed: { color: '#ef4444', fontWeight: '600' },
|
|
||||||
});
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { Stack } from 'expo-router';
|
|
||||||
import { SQLiteProvider } from 'expo-sqlite';
|
|
||||||
import { StatusBar } from 'expo-status-bar';
|
|
||||||
import { migrateDb } from '@/db';
|
|
||||||
import { ThemeProvider } from '@/ThemeContext';
|
|
||||||
|
|
||||||
export default function RootLayout() {
|
|
||||||
return (
|
|
||||||
<SQLiteProvider databaseName="bincio.db" onInit={migrateDb}>
|
|
||||||
<ThemeProvider>
|
|
||||||
<StatusBar style="light" />
|
|
||||||
<Stack screenOptions={{ headerShown: false }} />
|
|
||||||
</ThemeProvider>
|
|
||||||
</SQLiteProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,531 +0,0 @@
|
|||||||
import { Camera, GeoJSONSource, Layer, Map } from '@maplibre/maplibre-react-native';
|
|
||||||
import * as FileSystem from 'expo-file-system';
|
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { Alert, Modal, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
|
|
||||||
import Svg, { Defs, LinearGradient, Path, Stop } from 'react-native-svg';
|
|
||||||
import { useSQLiteContext } from 'expo-sqlite';
|
|
||||||
import { deleteActivity, setActivityTitle, useActivity, useSetting } from '@/db/queries';
|
|
||||||
import { useTheme } from '@/ThemeContext';
|
|
||||||
|
|
||||||
const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
|
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type Timeseries = {
|
|
||||||
t: number[];
|
|
||||||
elevation_m: (number | null)[];
|
|
||||||
speed_kmh?: (number | null)[] | null;
|
|
||||||
hr_bpm?: (number | null)[] | null;
|
|
||||||
cadence_rpm?: (number | null)[] | null;
|
|
||||||
power_w?: (number | null)[] | null;
|
|
||||||
lat?: (number | null)[] | null;
|
|
||||||
lon?: (number | null)[] | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Screen ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default function ActivityScreen() {
|
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
|
||||||
const router = useRouter();
|
|
||||||
const db = useSQLiteContext();
|
|
||||||
const theme = useTheme();
|
|
||||||
const row = useActivity(id);
|
|
||||||
const instanceUrl = useSetting('instance_url')?.replace(/\/$/, '') ?? '';
|
|
||||||
const token = useSetting('api_token') ?? '';
|
|
||||||
|
|
||||||
const [geojson, setGeojson] = useState<object | null>(null);
|
|
||||||
const [timeseries, setTimeseries] = useState<Timeseries | null>(null);
|
|
||||||
const [loadingMap, setLoadingMap] = useState(false);
|
|
||||||
const [loadingChart, setLoadingChart] = useState(false);
|
|
||||||
const [editingTitle, setEditingTitle] = useState(false);
|
|
||||||
const [titleDraft, setTitleDraft] = useState('');
|
|
||||||
|
|
||||||
async function confirmDelete() {
|
|
||||||
Alert.alert(
|
|
||||||
'Delete activity',
|
|
||||||
'This will permanently remove this activity from your device.',
|
|
||||||
[
|
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
|
||||||
{
|
|
||||||
text: 'Delete',
|
|
||||||
style: 'destructive',
|
|
||||||
onPress: async () => {
|
|
||||||
const originalPath = await deleteActivity(db, id);
|
|
||||||
if (originalPath) {
|
|
||||||
try { await FileSystem.deleteAsync(originalPath, { idempotent: true }); } catch {}
|
|
||||||
}
|
|
||||||
router.back();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// instanceUrl and token are in the dep array to avoid a stale-closure bug in
|
|
||||||
// release builds: Hermes executes effects sooner and captures empty strings if
|
|
||||||
// the deps are omitted. Guards on geojson/timeseries prevent double-fetching.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!row) return;
|
|
||||||
|
|
||||||
if (row.geojson) {
|
|
||||||
setGeojson(JSON.parse(row.geojson));
|
|
||||||
} else if (row.origin === 'remote' && instanceUrl && token) {
|
|
||||||
setLoadingMap(true);
|
|
||||||
fetch(`${instanceUrl}/api/activity/${row.id}/geojson`, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
.then(r => r.ok ? r.json() : null)
|
|
||||||
.then(data => { if (data) setGeojson(data); })
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoadingMap(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (row.timeseries_json) {
|
|
||||||
setTimeseries(JSON.parse(row.timeseries_json));
|
|
||||||
} else if (row.origin === 'remote' && instanceUrl && token) {
|
|
||||||
setLoadingChart(true);
|
|
||||||
fetch(`${instanceUrl}/api/activity/${row.id}/timeseries`, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
.then(r => r.ok ? r.json() : null)
|
|
||||||
.then(data => { if (data) setTimeseries(data); })
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoadingChart(false));
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [row?.id, instanceUrl, token]);
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
return (
|
|
||||||
<View style={styles.center}>
|
|
||||||
<Text style={styles.notFound}>Activity not found</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const detail = JSON.parse(row.detail_json);
|
|
||||||
const edits = row.edits_json ? JSON.parse(row.edits_json) : {};
|
|
||||||
const displayTitle = edits.title ?? detail.title;
|
|
||||||
const canEdit = row.origin === 'local';
|
|
||||||
const km = detail.distance_m != null ? (detail.distance_m / 1000).toFixed(2) : null;
|
|
||||||
const elev = detail.elevation_gain_m != null ? Math.round(detail.elevation_gain_m) : null;
|
|
||||||
const elevLoss = detail.elevation_loss_m != null ? Math.round(Math.abs(detail.elevation_loss_m)) : null;
|
|
||||||
const movingTime = detail.moving_time_s != null ? formatDuration(detail.moving_time_s) : null;
|
|
||||||
const speed = detail.avg_speed_kmh != null ? detail.avg_speed_kmh.toFixed(1) : null;
|
|
||||||
const hr = detail.avg_hr_bpm != null ? Math.round(detail.avg_hr_bpm) : null;
|
|
||||||
const power = detail.avg_power_w != null ? Math.round(detail.avg_power_w) : null;
|
|
||||||
const date = new Date(detail.started_at).toLocaleDateString(undefined, {
|
|
||||||
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
|
||||||
<View style={styles.topBar}>
|
|
||||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
|
||||||
<Text style={[styles.backText, { color: theme.accent }]}>← Back</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable style={styles.deleteButton} onPress={confirmDelete}>
|
|
||||||
<Text style={styles.deleteText}>Delete</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text style={styles.sport}>{detail.sport ?? 'Activity'}</Text>
|
|
||||||
{editingTitle ? (
|
|
||||||
<TextInput
|
|
||||||
style={styles.titleInput}
|
|
||||||
value={titleDraft}
|
|
||||||
onChangeText={setTitleDraft}
|
|
||||||
autoFocus
|
|
||||||
returnKeyType="done"
|
|
||||||
onEndEditing={(e) => {
|
|
||||||
const trimmed = e.nativeEvent.text.trim();
|
|
||||||
if (trimmed && trimmed !== displayTitle) {
|
|
||||||
setActivityTitle(db, id, trimmed);
|
|
||||||
}
|
|
||||||
setEditingTitle(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Pressable
|
|
||||||
onPress={canEdit ? () => { setTitleDraft(displayTitle); setEditingTitle(true); } : undefined}
|
|
||||||
style={styles.titleRow}
|
|
||||||
>
|
|
||||||
<Text style={styles.title}>{displayTitle}</Text>
|
|
||||||
{canEdit && <Text style={styles.editHint}>✎</Text>}
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
<Text style={styles.date}>{date}</Text>
|
|
||||||
|
|
||||||
{/* Map */}
|
|
||||||
<RouteMap geojson={geojson} loading={loadingMap} accent={theme.accent} />
|
|
||||||
|
|
||||||
{/* Stats grid */}
|
|
||||||
<View style={styles.grid}>
|
|
||||||
{km && <StatCell label="Distance" value={km} unit="km" />}
|
|
||||||
{movingTime && <StatCell label="Moving time" value={movingTime} unit="" />}
|
|
||||||
{elev != null && <StatCell label="Elev gain" value={String(elev)} unit="m" />}
|
|
||||||
{elevLoss != null && <StatCell label="Elev loss" value={String(elevLoss)} unit="m" />}
|
|
||||||
{speed && <StatCell label="Avg speed" value={speed} unit="km/h"/>}
|
|
||||||
{hr && <StatCell label="Avg HR" value={String(hr)} unit="bpm" />}
|
|
||||||
{power && <StatCell label="Avg power" value={String(power)} unit="W" />}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Metric charts */}
|
|
||||||
<MetricCharts timeseries={timeseries} loading={loadingChart} accent={theme.accent} />
|
|
||||||
|
|
||||||
{/* Meta */}
|
|
||||||
<View style={styles.meta}>
|
|
||||||
<MetaRow label="Source" value={detail.source ?? '—'} />
|
|
||||||
<MetaRow label="Device" value={detail.device ?? '—'} />
|
|
||||||
<MetaRow label="Origin" value={row.origin} />
|
|
||||||
<MetaRow label="Synced" value={row.synced_at ? new Date(row.synced_at * 1000).toLocaleDateString() : 'No'} />
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Map ───────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function RouteMap({ geojson, loading, accent }: { geojson: object | null; loading: boolean; accent: string }) {
|
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
|
||||||
const [currentZoom, setCurrentZoom] = useState(12);
|
|
||||||
const cameraRef = useRef<any>(null);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<View style={styles.mapPlaceholder}>
|
|
||||||
<Text style={{ color: accent, fontSize: 13 }}>Loading map…</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!geojson) return null;
|
|
||||||
|
|
||||||
// MapLibre uses OpenGL/SurfaceView which crashes the Karoo's Qualcomm GPU
|
|
||||||
// driver (Android <29) even without any interaction. Render a pure SVG route
|
|
||||||
// trace instead — no native GL surface, no crash.
|
|
||||||
if (Platform.OS === 'android' && (Platform.Version as number) < 29) {
|
|
||||||
return <SvgRouteView geojson={geojson} accent={accent} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bounds = geoJsonBounds(geojson);
|
|
||||||
const routeSource = (
|
|
||||||
<GeoJSONSource id="route" data={geojson as GeoJSON.FeatureCollection}>
|
|
||||||
<Layer
|
|
||||||
type="line"
|
|
||||||
id="route-line"
|
|
||||||
paint={{ 'line-color': accent, 'line-width': 3 }}
|
|
||||||
layout={{ 'line-cap': 'round', 'line-join': 'round' }}
|
|
||||||
/>
|
|
||||||
</GeoJSONSource>
|
|
||||||
);
|
|
||||||
const cameraBounds = bounds
|
|
||||||
? { bounds, padding: { top: 24, bottom: 24, left: 24, right: 24 } }
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Thumbnail — tap to expand */}
|
|
||||||
<Pressable style={styles.mapContainer} onPress={() => setFullscreen(true)}>
|
|
||||||
<Map style={styles.map} mapStyle={MAP_STYLE} dragPan={false} touchZoom={false} touchPitch={false} touchRotate={false}>
|
|
||||||
{cameraBounds && <Camera initialViewState={cameraBounds} />}
|
|
||||||
{routeSource}
|
|
||||||
</Map>
|
|
||||||
<View style={styles.mapExpandHint}>
|
|
||||||
<Text style={styles.mapExpandText}>⤢ tap to explore</Text>
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
{/* Full-screen map with +/- zoom buttons */}
|
|
||||||
<Modal visible={fullscreen} animationType="slide" onRequestClose={() => setFullscreen(false)}>
|
|
||||||
<View style={styles.fullscreenMap}>
|
|
||||||
<Map
|
|
||||||
style={styles.map}
|
|
||||||
mapStyle={MAP_STYLE}
|
|
||||||
onRegionDidChange={(e: any) => {
|
|
||||||
const z = e?.properties?.zoomLevel;
|
|
||||||
if (typeof z === 'number') setCurrentZoom(z);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{cameraBounds && <Camera ref={cameraRef} initialViewState={cameraBounds} />}
|
|
||||||
{routeSource}
|
|
||||||
</Map>
|
|
||||||
<Pressable style={styles.closeButton} onPress={() => setFullscreen(false)}>
|
|
||||||
<Text style={styles.closeText}>✕</Text>
|
|
||||||
</Pressable>
|
|
||||||
<View style={styles.zoomButtons}>
|
|
||||||
<Pressable style={styles.zoomBtn} onPress={() => cameraRef.current?.setCamera({ zoomLevel: currentZoom + 1, animationDuration: 200 })}>
|
|
||||||
<Text style={styles.zoomBtnText}>+</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable style={styles.zoomBtn} onPress={() => cameraRef.current?.setCamera({ zoomLevel: Math.max(1, currentZoom - 1), animationDuration: 200 })}>
|
|
||||||
<Text style={styles.zoomBtnText}>−</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// SVG route trace — used on Android <29 where MapLibre crashes the GPU driver.
|
|
||||||
// Renders the GPS track as a colored path on a dark background with no tiles.
|
|
||||||
function SvgRouteView({ geojson, accent }: { geojson: object; accent: string }) {
|
|
||||||
const W = 320;
|
|
||||||
const H = 180;
|
|
||||||
const PAD = 16;
|
|
||||||
|
|
||||||
const all: [number, number][] = [];
|
|
||||||
function collect(obj: unknown) {
|
|
||||||
if (!obj || typeof obj !== 'object') return;
|
|
||||||
const o = obj as Record<string, unknown>;
|
|
||||||
if (o.type === 'Feature') { collect(o.geometry); return; }
|
|
||||||
if (o.type === 'FeatureCollection') { (o.features as unknown[]).forEach(collect); return; }
|
|
||||||
if (o.type === 'LineString') { all.push(...(o.coordinates as [number, number][])); return; }
|
|
||||||
if (o.type === 'MultiLineString') { (o.coordinates as [number, number][][]).forEach(c => all.push(...c)); return; }
|
|
||||||
}
|
|
||||||
collect(geojson);
|
|
||||||
if (!all.length) return null;
|
|
||||||
|
|
||||||
const step = Math.max(1, Math.floor(all.length / 500));
|
|
||||||
const pts = all.filter((_, i) => i % step === 0);
|
|
||||||
|
|
||||||
const lons = pts.map(c => c[0]);
|
|
||||||
const lats = pts.map(c => c[1]);
|
|
||||||
const minLon = Math.min(...lons), maxLon = Math.max(...lons);
|
|
||||||
const minLat = Math.min(...lats), maxLat = Math.max(...lats);
|
|
||||||
const spanLon = maxLon - minLon || 0.001;
|
|
||||||
const spanLat = maxLat - minLat || 0.001;
|
|
||||||
|
|
||||||
// Correct longitude for latitude (equirectangular)
|
|
||||||
const midLat = (minLat + maxLat) / 2;
|
|
||||||
const lonFactor = Math.cos((midLat * Math.PI) / 180);
|
|
||||||
const adjLon = spanLon * lonFactor;
|
|
||||||
|
|
||||||
const scale = Math.min((W - PAD * 2) / adjLon, (H - PAD * 2) / spanLat);
|
|
||||||
const offX = (W - adjLon * scale) / 2;
|
|
||||||
const offY = (H - spanLat * scale) / 2;
|
|
||||||
|
|
||||||
const toX = (lon: number) => offX + (lon - minLon) * lonFactor * scale;
|
|
||||||
const toY = (lat: number) => H - offY - (lat - minLat) * scale;
|
|
||||||
|
|
||||||
const d = pts.map((c, i) => `${i === 0 ? 'M' : 'L'}${toX(c[0]).toFixed(1)},${toY(c[1]).toFixed(1)}`).join(' ');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={[styles.mapContainer, { alignItems: 'center', justifyContent: 'center' }]}>
|
|
||||||
<Svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
|
|
||||||
<Path d={d} fill="none" stroke={accent} strokeWidth="2.5" strokeLinejoin="round" strokeLinecap="round" />
|
|
||||||
</Svg>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Metric charts ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type TabKey = 'elevation' | 'speed' | 'hr' | 'cadence' | 'power';
|
|
||||||
|
|
||||||
const TAB_META: Record<TabKey, { label: string; unit: string; color: string; decimals: number }> = {
|
|
||||||
elevation: { label: 'Elevation', unit: 'm', color: '#00c8ff', decimals: 0 },
|
|
||||||
speed: { label: 'Speed', unit: 'km/h', color: '#ff6b35', decimals: 1 },
|
|
||||||
hr: { label: 'HR', unit: 'bpm', color: '#f87171', decimals: 0 },
|
|
||||||
cadence: { label: 'Cadence', unit: 'rpm', color: '#a78bfa', decimals: 0 },
|
|
||||||
power: { label: 'Power', unit: 'W', color: '#facc15', decimals: 0 },
|
|
||||||
};
|
|
||||||
|
|
||||||
function MetricCharts({ timeseries, loading, accent }: { timeseries: Timeseries | null; loading: boolean; accent: string }) {
|
|
||||||
const [activeTab, setActiveTab] = useState<TabKey>('elevation');
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<View style={styles.chartPlaceholder}>
|
|
||||||
<Text style={{ color: accent, fontSize: 13 }}>Loading chart…</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!timeseries) return null;
|
|
||||||
|
|
||||||
const seriesMap: Record<TabKey, (number | null)[] | null | undefined> = {
|
|
||||||
elevation: timeseries.elevation_m,
|
|
||||||
speed: timeseries.speed_kmh,
|
|
||||||
hr: timeseries.hr_bpm,
|
|
||||||
cadence: timeseries.cadence_rpm,
|
|
||||||
power: timeseries.power_w,
|
|
||||||
};
|
|
||||||
|
|
||||||
const available = (Object.keys(TAB_META) as TabKey[]).filter(
|
|
||||||
k => seriesMap[k]?.some(v => v != null)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!available.length) return null;
|
|
||||||
|
|
||||||
const tab = available.includes(activeTab) ? activeTab : available[0];
|
|
||||||
const { color, unit, decimals } = TAB_META[tab];
|
|
||||||
const raw = seriesMap[tab]!;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.chartContainer}>
|
|
||||||
{/* Tab row */}
|
|
||||||
<View style={styles.chartTabs}>
|
|
||||||
{available.map(k => (
|
|
||||||
<Pressable
|
|
||||||
key={k}
|
|
||||||
style={[styles.chartTab, tab === k && { borderBottomColor: TAB_META[k].color, borderBottomWidth: 2 }]}
|
|
||||||
onPress={() => setActiveTab(k)}
|
|
||||||
>
|
|
||||||
<Text style={[styles.chartTabText, tab === k && { color: TAB_META[k].color }]}>
|
|
||||||
{TAB_META[k].label}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
{/* Chart */}
|
|
||||||
<MetricChart key={tab} times={timeseries.t} values={raw} color={color} unit={unit} decimals={decimals} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetricChart({
|
|
||||||
times, values, color, unit, decimals,
|
|
||||||
}: {
|
|
||||||
times: number[];
|
|
||||||
values: (number | null)[];
|
|
||||||
color: string;
|
|
||||||
unit: string;
|
|
||||||
decimals: number;
|
|
||||||
}) {
|
|
||||||
const W = 340;
|
|
||||||
const H = 100;
|
|
||||||
const PAD = 4;
|
|
||||||
|
|
||||||
// Downsample to ≤300 points
|
|
||||||
const step = Math.max(1, Math.floor(values.length / 300));
|
|
||||||
const ts = times.filter((_, i) => i % step === 0);
|
|
||||||
const vs = values.filter((_, i) => i % step === 0).map(v => v ?? 0);
|
|
||||||
|
|
||||||
const minV = Math.min(...vs);
|
|
||||||
const maxV = Math.max(...vs);
|
|
||||||
const range = maxV - minV || 1;
|
|
||||||
const maxT = ts[ts.length - 1] || 1;
|
|
||||||
|
|
||||||
const x = (t: number) => PAD + (t / maxT) * (W - PAD * 2);
|
|
||||||
const y = (v: number) => PAD + (1 - (v - minV) / range) * (H - PAD * 2);
|
|
||||||
|
|
||||||
const pts = ts.map((t, i) => `${x(t).toFixed(1)},${y(vs[i]).toFixed(1)}`);
|
|
||||||
const linePath = `M ${pts.join(' L ')}`;
|
|
||||||
const areaPath = `M ${x(ts[0])},${H} L ${pts.join(' L ')} L ${x(maxT)},${H} Z`;
|
|
||||||
const gradId = `grad-${color.replace('#', '')}`;
|
|
||||||
|
|
||||||
const fmt = (v: number) => decimals === 0 ? String(Math.round(v)) : v.toFixed(decimals);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Text style={[styles.chartLabel, { color }]}>{fmt(maxV)} {unit}</Text>
|
|
||||||
<Svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
|
|
||||||
<Defs>
|
|
||||||
<LinearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<Stop offset="0" stopColor={color} stopOpacity="0.35" />
|
|
||||||
<Stop offset="1" stopColor={color} stopOpacity="0.02" />
|
|
||||||
</LinearGradient>
|
|
||||||
</Defs>
|
|
||||||
<Path d={areaPath} fill={`url(#${gradId})`} />
|
|
||||||
<Path d={linePath} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" />
|
|
||||||
</Svg>
|
|
||||||
<Text style={[styles.chartLabel, { color: '#3f3f46', marginBottom: 10 }]}>{fmt(minV)} {unit}</Text>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// Returns [west, south, east, north] per LngLatBounds spec
|
|
||||||
function geoJsonBounds(gj: object): [number, number, number, number] | null {
|
|
||||||
const coords: [number, number][] = [];
|
|
||||||
function collect(obj: unknown) {
|
|
||||||
if (!obj || typeof obj !== 'object') return;
|
|
||||||
const o = obj as Record<string, unknown>;
|
|
||||||
if (o.type === 'Feature') { collect(o.geometry); return; }
|
|
||||||
if (o.type === 'FeatureCollection') { (o.features as unknown[]).forEach(collect); return; }
|
|
||||||
if (o.type === 'LineString') { coords.push(...(o.coordinates as [number, number][])); return; }
|
|
||||||
if (o.type === 'MultiLineString') { (o.coordinates as [number, number][][]).forEach(c => coords.push(...c)); return; }
|
|
||||||
}
|
|
||||||
collect(gj);
|
|
||||||
if (!coords.length) return null;
|
|
||||||
const lons = coords.map(c => c[0]);
|
|
||||||
const lats = coords.map(c => c[1]);
|
|
||||||
return [Math.min(...lons), Math.min(...lats), Math.max(...lons), Math.max(...lats)];
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
|
||||||
const h = Math.floor(seconds / 3600);
|
|
||||||
const m = Math.floor((seconds % 3600) / 60);
|
|
||||||
const s = seconds % 60;
|
|
||||||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
|
||||||
return `${m}:${String(s).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatCell({ label, value, unit }: { label: string; value: string; unit: string }) {
|
|
||||||
return (
|
|
||||||
<View style={styles.statCell}>
|
|
||||||
<View style={styles.statValueRow}>
|
|
||||||
<Text style={styles.statValue}>{value}</Text>
|
|
||||||
{unit ? <Text style={styles.statUnit}>{unit}</Text> : null}
|
|
||||||
</View>
|
|
||||||
<Text style={styles.statLabel}>{label}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetaRow({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<View style={styles.metaRow}>
|
|
||||||
<Text style={styles.metaLabel}>{label}</Text>
|
|
||||||
<Text style={styles.metaValue}>{value}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Styles ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: { flex: 1, backgroundColor: '#09090b' },
|
|
||||||
content: { paddingBottom: 40 },
|
|
||||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#09090b' },
|
|
||||||
notFound: { color: '#71717a', fontSize: 16 },
|
|
||||||
topBar: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingTop: 60, paddingBottom: 12 },
|
|
||||||
backButton: { paddingHorizontal: 16 },
|
|
||||||
backText: { fontSize: 15 },
|
|
||||||
deleteButton: { paddingHorizontal: 16 },
|
|
||||||
deleteText: { color: '#f87171', fontSize: 15 },
|
|
||||||
sport: { color: '#71717a', fontSize: 12, fontWeight: '600', letterSpacing: 0.8, paddingHorizontal: 16, marginBottom: 4 },
|
|
||||||
titleRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, marginBottom: 4 },
|
|
||||||
title: { color: '#f4f4f5', fontSize: 22, fontWeight: '700', flexShrink: 1 },
|
|
||||||
titleInput: { color: '#f4f4f5', fontSize: 22, fontWeight: '700', paddingHorizontal: 16, marginBottom: 4, borderBottomWidth: 1, borderBottomColor: '#3b82f6' },
|
|
||||||
editHint: { color: '#52525b', fontSize: 16, marginLeft: 8 },
|
|
||||||
date: { color: '#71717a', fontSize: 13, paddingHorizontal: 16, marginBottom: 16 },
|
|
||||||
mapContainer: { height: 220, marginBottom: 16, borderTopWidth: 1, borderBottomWidth: 1, borderColor: '#27272a' },
|
|
||||||
map: { flex: 1 },
|
|
||||||
mapPlaceholder: { height: 220, backgroundColor: '#18181b', alignItems: 'center', justifyContent: 'center', borderTopWidth: 1, borderBottomWidth: 1, borderColor: '#27272a', marginBottom: 16 },
|
|
||||||
mapExpandHint: { position: 'absolute', bottom: 8, right: 8, backgroundColor: 'rgba(0,0,0,0.55)', borderRadius: 6, paddingHorizontal: 8, paddingVertical: 4 },
|
|
||||||
mapExpandText: { color: '#a1a1aa', fontSize: 11 },
|
|
||||||
fullscreenMap: { flex: 1, backgroundColor: '#09090b' },
|
|
||||||
closeButton: { position: 'absolute', top: 56, right: 16, backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 20, width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
|
|
||||||
closeText: { color: '#fff', fontSize: 16 },
|
|
||||||
zoomButtons: { position: 'absolute', bottom: 40, right: 16, gap: 8 },
|
|
||||||
zoomBtn: { backgroundColor: 'rgba(0,0,0,0.65)', borderRadius: 20, width: 40, height: 40, alignItems: 'center', justifyContent: 'center' },
|
|
||||||
zoomBtnText: { color: '#fff', fontSize: 22, fontWeight: '600', lineHeight: 28 },
|
|
||||||
chartContainer: { marginHorizontal: 16, marginBottom: 16, backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', overflow: 'hidden' },
|
|
||||||
chartPlaceholder: { height: 120, backgroundColor: '#18181b', alignItems: 'center', justifyContent: 'center', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', marginHorizontal: 16, marginBottom: 16 },
|
|
||||||
chartTabs: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#27272a' },
|
|
||||||
chartTab: { flex: 1, paddingVertical: 8, alignItems: 'center', borderBottomWidth: 2, borderBottomColor: 'transparent' },
|
|
||||||
chartTabText: { color: '#52525b', fontSize: 11, fontWeight: '600' },
|
|
||||||
chartLabel: { color: '#3f3f46', fontSize: 10, marginBottom: 2, marginHorizontal: 12, marginTop: 10 },
|
|
||||||
grid: { flexDirection: 'row', flexWrap: 'wrap', paddingHorizontal: 12, gap: 8, marginBottom: 16 },
|
|
||||||
statCell: { backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', padding: 14, width: '47%' },
|
|
||||||
statValueRow: { flexDirection: 'row', alignItems: 'baseline', gap: 4, marginBottom: 4 },
|
|
||||||
statValue: { color: '#f4f4f5', fontSize: 24, fontWeight: '700' },
|
|
||||||
statUnit: { color: '#71717a', fontSize: 13 },
|
|
||||||
statLabel: { color: '#71717a', fontSize: 12 },
|
|
||||||
meta: { marginHorizontal: 16, backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, borderColor: '#27272a' },
|
|
||||||
metaRow: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 14, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: '#27272a' },
|
|
||||||
metaLabel: { color: '#71717a', fontSize: 13 },
|
|
||||||
metaValue: { color: '#a1a1aa', fontSize: 13 },
|
|
||||||
});
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB |
@@ -1,6 +0,0 @@
|
|||||||
module.exports = function (api) {
|
|
||||||
api.cache(true);
|
|
||||||
return {
|
|
||||||
presets: ['babel-preset-expo'],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import { useRouter } from 'expo-router';
|
|
||||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
|
||||||
import type { ActivitySummary } from '@/db/queries';
|
|
||||||
import { useTheme } from '@/ThemeContext';
|
|
||||||
|
|
||||||
export function ActivityCard({
|
|
||||||
activity,
|
|
||||||
selecting,
|
|
||||||
checked,
|
|
||||||
onToggleSelect,
|
|
||||||
onLongPress,
|
|
||||||
}: {
|
|
||||||
activity: ActivitySummary;
|
|
||||||
selecting: boolean;
|
|
||||||
checked: boolean;
|
|
||||||
onToggleSelect: () => void;
|
|
||||||
onLongPress: () => void;
|
|
||||||
}) {
|
|
||||||
const router = useRouter();
|
|
||||||
const theme = useTheme();
|
|
||||||
const km = activity.distance_m != null ? (activity.distance_m / 1000).toFixed(1) : null;
|
|
||||||
const elev = activity.elevation_gain_m != null ? Math.round(activity.elevation_gain_m) : null;
|
|
||||||
const date = new Date(activity.started_at).toLocaleDateString(undefined, {
|
|
||||||
day: 'numeric', month: 'short', year: 'numeric',
|
|
||||||
});
|
|
||||||
|
|
||||||
function handlePress() {
|
|
||||||
if (selecting) onToggleSelect();
|
|
||||||
else router.push(`/activity/${activity.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
style={[styles.card, checked && { borderColor: theme.accent }]}
|
|
||||||
onPress={handlePress}
|
|
||||||
onLongPress={onLongPress}
|
|
||||||
>
|
|
||||||
<View style={styles.cardTop}>
|
|
||||||
<View style={styles.cardLeft}>
|
|
||||||
{selecting && (
|
|
||||||
<View style={[styles.checkbox, checked && { backgroundColor: theme.accent, borderColor: theme.accent }]}>
|
|
||||||
{checked && <Text style={styles.checkmark}>✓</Text>}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<Text style={styles.sportIcon}>{sportIcon(activity.sport)}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.cardMeta}>
|
|
||||||
<Text style={styles.cardDate}>{date}</Text>
|
|
||||||
{activity.origin === 'remote'
|
|
||||||
? <Text style={[styles.remoteBadge, { color: theme.accent, borderColor: theme.accent }]}>cloud</Text>
|
|
||||||
: !activity.synced_at && <Text style={styles.localBadge}>local</Text>
|
|
||||||
}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.cardTitle} numberOfLines={1}>{activity.user_title ?? activity.title}</Text>
|
|
||||||
<View style={styles.cardStats}>
|
|
||||||
{km && <Stat label="km" value={km} />}
|
|
||||||
{elev != null && <Stat label="m↑" value={String(elev)} />}
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Stat({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<View style={styles.stat}>
|
|
||||||
<Text style={styles.statValue}>{value}</Text>
|
|
||||||
<Text style={styles.statLabel}>{label}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sportIcon(sport: string): string {
|
|
||||||
const icons: Record<string, string> = {
|
|
||||||
cycling: '🚴', running: '🏃', hiking: '🥾', swimming: '🏊', walking: '🚶',
|
|
||||||
};
|
|
||||||
return icons[sport] ?? '🏅';
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
card: {
|
|
||||||
backgroundColor: '#18181b', borderRadius: 12,
|
|
||||||
padding: 16, borderWidth: 1, borderColor: '#27272a',
|
|
||||||
},
|
|
||||||
cardTop: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 },
|
|
||||||
cardLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 },
|
|
||||||
sportIcon: { fontSize: 20 },
|
|
||||||
cardMeta: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
|
||||||
cardDate: { color: '#71717a', fontSize: 12 },
|
|
||||||
remoteBadge: {
|
|
||||||
fontSize: 10, borderWidth: 1,
|
|
||||||
borderRadius: 4, paddingHorizontal: 4,
|
|
||||||
},
|
|
||||||
localBadge: {
|
|
||||||
color: '#a1a1aa', fontSize: 10, borderWidth: 1,
|
|
||||||
borderColor: '#3f3f46', borderRadius: 4, paddingHorizontal: 4,
|
|
||||||
},
|
|
||||||
cardTitle: { color: '#f4f4f5', fontSize: 15, fontWeight: '600', marginBottom: 10 },
|
|
||||||
cardStats: { flexDirection: 'row', gap: 16 },
|
|
||||||
stat: { flexDirection: 'row', alignItems: 'baseline', gap: 3 },
|
|
||||||
statValue: { color: '#f4f4f5', fontSize: 16, fontWeight: '600' },
|
|
||||||
statLabel: { color: '#71717a', fontSize: 12 },
|
|
||||||
checkbox: {
|
|
||||||
width: 20, height: 20, borderRadius: 4, borderWidth: 1.5,
|
|
||||||
borderColor: '#52525b', alignItems: 'center', justifyContent: 'center',
|
|
||||||
},
|
|
||||||
checkmark: { color: '#fff', fontSize: 12, fontWeight: '700' },
|
|
||||||
});
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import type { SQLiteDatabase } from 'expo-sqlite';
|
|
||||||
|
|
||||||
export async function migrateDb(db: SQLiteDatabase): Promise<void> {
|
|
||||||
await db.execAsync('PRAGMA journal_mode = WAL;');
|
|
||||||
await db.execAsync(`
|
|
||||||
CREATE TABLE IF NOT EXISTS activities (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
source_hash TEXT NOT NULL,
|
|
||||||
detail_json TEXT NOT NULL,
|
|
||||||
timeseries_json TEXT,
|
|
||||||
geojson TEXT,
|
|
||||||
original_path TEXT,
|
|
||||||
synced_at INTEGER,
|
|
||||||
origin TEXT NOT NULL CHECK(origin IN ('local', 'remote')),
|
|
||||||
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_activities_created_at
|
|
||||||
ON activities(created_at DESC);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT NOT NULL
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Migration v2: source_path stores the original filesystem path a file was
|
|
||||||
// imported from (e.g. /sdcard/Karoo/Rides/ride.fit), used for watch-folder
|
|
||||||
// deduplication without re-hashing files.
|
|
||||||
try {
|
|
||||||
await db.execAsync('ALTER TABLE activities ADD COLUMN source_path TEXT');
|
|
||||||
await db.execAsync(
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_activities_source_path ON activities(source_path)',
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// Column already exists — migration already ran, ignore.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Migration v3: edits_json stores user overrides (e.g. {"title": "My title"})
|
|
||||||
// kept separate from detail_json so server re-extraction (Option A) never
|
|
||||||
// clobbers user edits.
|
|
||||||
try {
|
|
||||||
await db.execAsync('ALTER TABLE activities ADD COLUMN edits_json TEXT');
|
|
||||||
} catch {
|
|
||||||
// Column already exists — migration already ran, ignore.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
import { useSQLiteContext } from 'expo-sqlite';
|
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type ActivityRow = {
|
|
||||||
id: string;
|
|
||||||
source_hash: string;
|
|
||||||
detail_json: string;
|
|
||||||
timeseries_json: string | null;
|
|
||||||
geojson: string | null;
|
|
||||||
original_path: string | null;
|
|
||||||
source_path: string | null;
|
|
||||||
synced_at: number | null;
|
|
||||||
origin: 'local' | 'remote';
|
|
||||||
created_at: number;
|
|
||||||
edits_json: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ActivitySummary = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
user_title: string | null; // from edits_json; takes display priority over title
|
|
||||||
sport: string;
|
|
||||||
started_at: string;
|
|
||||||
distance_m: number | null;
|
|
||||||
duration_s: number | null;
|
|
||||||
elevation_gain_m: number | null;
|
|
||||||
origin: 'local' | 'remote';
|
|
||||||
synced_at: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Activities ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
|
||||||
|
|
||||||
export function useActivities(searchQuery = '', limit = PAGE_SIZE): ActivitySummary[] {
|
|
||||||
const db = useSQLiteContext();
|
|
||||||
const like = `%${searchQuery}%`;
|
|
||||||
const rows = db.getAllSync<ActivitySummary>(`
|
|
||||||
SELECT
|
|
||||||
id, origin, synced_at,
|
|
||||||
json_extract(detail_json, '$.title') AS title,
|
|
||||||
json_extract(edits_json, '$.title') AS user_title,
|
|
||||||
json_extract(detail_json, '$.sport') AS sport,
|
|
||||||
json_extract(detail_json, '$.started_at') AS started_at,
|
|
||||||
json_extract(detail_json, '$.distance_m') AS distance_m,
|
|
||||||
json_extract(detail_json, '$.duration_s') AS duration_s,
|
|
||||||
json_extract(detail_json, '$.elevation_gain_m') AS elevation_gain_m
|
|
||||||
FROM activities
|
|
||||||
WHERE (? = '' OR json_extract(detail_json, '$.title') LIKE ?)
|
|
||||||
ORDER BY json_extract(detail_json, '$.started_at') DESC
|
|
||||||
LIMIT ?
|
|
||||||
`, [searchQuery, like, limit]);
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useActivityCount(searchQuery = ''): number {
|
|
||||||
const db = useSQLiteContext();
|
|
||||||
const like = `%${searchQuery}%`;
|
|
||||||
const row = db.getFirstSync<{ n: number }>(
|
|
||||||
`SELECT COUNT(*) as n FROM activities
|
|
||||||
WHERE (? = '' OR json_extract(detail_json, '$.title') LIKE ?)`,
|
|
||||||
[searchQuery, like],
|
|
||||||
);
|
|
||||||
return row?.n ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { PAGE_SIZE };
|
|
||||||
|
|
||||||
export type ActivityFilter = {
|
|
||||||
sport: string; // '' = all sports
|
|
||||||
dateFrom: string; // '' = no lower bound; ISO-like 'YYYY-MM-DDTHHMMSSZ' for comparison
|
|
||||||
dateTo: string; // '' = no upper bound
|
|
||||||
sort: 'date' | 'distance' | 'elevation';
|
|
||||||
};
|
|
||||||
|
|
||||||
const SORT_SQL: Record<string, string> = {
|
|
||||||
date: "json_extract(detail_json, '$.started_at') DESC",
|
|
||||||
distance: "json_extract(detail_json, '$.distance_m') DESC",
|
|
||||||
elevation: "json_extract(detail_json, '$.elevation_gain_m') DESC",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useFilteredActivities(filter: ActivityFilter, limit = PAGE_SIZE): ActivitySummary[] {
|
|
||||||
const db = useSQLiteContext();
|
|
||||||
const order = SORT_SQL[filter.sort] ?? SORT_SQL.date;
|
|
||||||
return db.getAllSync<ActivitySummary>(`
|
|
||||||
SELECT
|
|
||||||
id, origin, synced_at,
|
|
||||||
json_extract(detail_json, '$.title') AS title,
|
|
||||||
json_extract(edits_json, '$.title') AS user_title,
|
|
||||||
json_extract(detail_json, '$.sport') AS sport,
|
|
||||||
json_extract(detail_json, '$.started_at') AS started_at,
|
|
||||||
json_extract(detail_json, '$.distance_m') AS distance_m,
|
|
||||||
json_extract(detail_json, '$.duration_s') AS duration_s,
|
|
||||||
json_extract(detail_json, '$.elevation_gain_m') AS elevation_gain_m
|
|
||||||
FROM activities
|
|
||||||
WHERE (? = '' OR json_extract(detail_json, '$.sport') = ?)
|
|
||||||
AND (? = '' OR json_extract(detail_json, '$.started_at') >= ?)
|
|
||||||
AND (? = '' OR json_extract(detail_json, '$.started_at') < ?)
|
|
||||||
ORDER BY ${order}
|
|
||||||
LIMIT ?
|
|
||||||
`, [filter.sport, filter.sport, filter.dateFrom, filter.dateFrom, filter.dateTo, filter.dateTo, limit]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useFilteredCount(filter: ActivityFilter): number {
|
|
||||||
const db = useSQLiteContext();
|
|
||||||
const row = db.getFirstSync<{ n: number }>(`
|
|
||||||
SELECT COUNT(*) as n FROM activities
|
|
||||||
WHERE (? = '' OR json_extract(detail_json, '$.sport') = ?)
|
|
||||||
AND (? = '' OR json_extract(detail_json, '$.started_at') >= ?)
|
|
||||||
AND (? = '' OR json_extract(detail_json, '$.started_at') < ?)
|
|
||||||
`, [filter.sport, filter.sport, filter.dateFrom, filter.dateFrom, filter.dateTo, filter.dateTo]);
|
|
||||||
return row?.n ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useActivityYears(): string[] {
|
|
||||||
const db = useSQLiteContext();
|
|
||||||
const rows = db.getAllSync<{ year: string }>(
|
|
||||||
`SELECT DISTINCT substr(json_extract(detail_json, '$.started_at'), 1, 4) AS year
|
|
||||||
FROM activities
|
|
||||||
WHERE json_extract(detail_json, '$.started_at') IS NOT NULL
|
|
||||||
ORDER BY year DESC`,
|
|
||||||
);
|
|
||||||
return rows.map(r => r.year).filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useActivity(id: string): ActivityRow | null {
|
|
||||||
const db = useSQLiteContext();
|
|
||||||
return db.getFirstSync<ActivityRow>(
|
|
||||||
'SELECT * FROM activities WHERE id = ?',
|
|
||||||
[id],
|
|
||||||
) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function insertActivity(
|
|
||||||
db: ReturnType<typeof useSQLiteContext>,
|
|
||||||
row: Pick<ActivityRow, 'id' | 'source_hash' | 'detail_json' | 'timeseries_json' | 'geojson' | 'original_path' | 'origin'>
|
|
||||||
& { source_path?: string | null },
|
|
||||||
): Promise<void> {
|
|
||||||
await db.runAsync(
|
|
||||||
`INSERT OR IGNORE INTO activities
|
|
||||||
(id, source_hash, detail_json, timeseries_json, geojson, original_path, source_path, origin)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
[
|
|
||||||
row.id,
|
|
||||||
row.source_hash,
|
|
||||||
row.detail_json,
|
|
||||||
row.timeseries_json ?? null,
|
|
||||||
row.geojson ?? null,
|
|
||||||
row.original_path ?? null,
|
|
||||||
row.source_path ?? null,
|
|
||||||
row.origin,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSourcePathImported(
|
|
||||||
db: ReturnType<typeof useSQLiteContext>,
|
|
||||||
sourcePath: string,
|
|
||||||
): boolean {
|
|
||||||
const row = db.getFirstSync<{ id: string }>(
|
|
||||||
'SELECT id FROM activities WHERE source_path = ?',
|
|
||||||
[sourcePath],
|
|
||||||
);
|
|
||||||
return row != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function upsertRemoteActivity(
|
|
||||||
db: ReturnType<typeof useSQLiteContext>,
|
|
||||||
id: string,
|
|
||||||
detailJson: string,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const result = await db.runAsync(
|
|
||||||
`INSERT INTO activities (id, source_hash, detail_json, origin, synced_at)
|
|
||||||
VALUES (?, ?, ?, 'remote', ?)
|
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
|
||||||
detail_json = excluded.detail_json,
|
|
||||||
synced_at = excluded.synced_at
|
|
||||||
WHERE origin = 'remote'`,
|
|
||||||
[id, id, detailJson, now],
|
|
||||||
);
|
|
||||||
return result.changes > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteRemoteActivities(
|
|
||||||
db: ReturnType<typeof useSQLiteContext>,
|
|
||||||
): Promise<number> {
|
|
||||||
const result = await db.runAsync(`DELETE FROM activities WHERE origin = 'remote'`);
|
|
||||||
return result.changes;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteActivity(
|
|
||||||
db: ReturnType<typeof useSQLiteContext>,
|
|
||||||
id: string,
|
|
||||||
): Promise<string | null> {
|
|
||||||
const row = db.getFirstSync<{ original_path: string | null }>(
|
|
||||||
'SELECT original_path FROM activities WHERE id = ?',
|
|
||||||
[id],
|
|
||||||
);
|
|
||||||
await db.runAsync('DELETE FROM activities WHERE id = ?', [id]);
|
|
||||||
return row?.original_path ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setActivityTitle(
|
|
||||||
db: ReturnType<typeof useSQLiteContext>,
|
|
||||||
id: string,
|
|
||||||
title: string,
|
|
||||||
): Promise<void> {
|
|
||||||
await db.runAsync(
|
|
||||||
`UPDATE activities
|
|
||||||
SET edits_json = json_set(COALESCE(edits_json, '{}'), '$.title', ?)
|
|
||||||
WHERE id = ?`,
|
|
||||||
[title, id],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteActivities(
|
|
||||||
db: ReturnType<typeof useSQLiteContext>,
|
|
||||||
ids: string[],
|
|
||||||
): Promise<Array<string | null>> {
|
|
||||||
if (ids.length === 0) return [];
|
|
||||||
const rows = db.getAllSync<{ original_path: string | null }>(
|
|
||||||
`SELECT original_path FROM activities WHERE id IN (${ids.map(() => '?').join(',')})`,
|
|
||||||
ids,
|
|
||||||
);
|
|
||||||
const placeholders = ids.map(() => '?').join(',');
|
|
||||||
await db.runAsync(`DELETE FROM activities WHERE id IN (${placeholders})`, ids);
|
|
||||||
return rows.map(r => r.original_path ?? null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Settings ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function getSetting(
|
|
||||||
db: ReturnType<typeof useSQLiteContext>,
|
|
||||||
key: string,
|
|
||||||
): Promise<string | null> {
|
|
||||||
const row = db.getFirstSync<{ value: string }>(
|
|
||||||
'SELECT value FROM settings WHERE key = ?',
|
|
||||||
[key],
|
|
||||||
);
|
|
||||||
return row?.value ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setSetting(
|
|
||||||
db: ReturnType<typeof useSQLiteContext>,
|
|
||||||
key: string,
|
|
||||||
value: string,
|
|
||||||
): Promise<void> {
|
|
||||||
await db.runAsync(
|
|
||||||
`INSERT INTO settings (key, value) VALUES (?, ?)
|
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
|
||||||
[key, value],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSetting(key: string): string | null {
|
|
||||||
const db = useSQLiteContext();
|
|
||||||
const row = db.getFirstSync<{ value: string }>(
|
|
||||||
'SELECT value FROM settings WHERE key = ?',
|
|
||||||
[key],
|
|
||||||
);
|
|
||||||
return row?.value ?? null;
|
|
||||||
}
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
import * as FileSystem from 'expo-file-system/legacy';
|
|
||||||
import type { SQLiteDatabase } from 'expo-sqlite';
|
|
||||||
import { getSetting, upsertRemoteActivity } from './queries';
|
|
||||||
|
|
||||||
export type SyncResult = {
|
|
||||||
synced: number;
|
|
||||||
total: number;
|
|
||||||
fetched?: number;
|
|
||||||
uploaded?: number;
|
|
||||||
failed?: number;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function resolveCredentials(db: SQLiteDatabase): Promise<{ instanceUrl: string; token: string } | { error: string }> {
|
|
||||||
const instanceUrl = (await getSetting(db, 'instance_url'))?.replace(/\/$/, '');
|
|
||||||
const token = await getSetting(db, 'api_token');
|
|
||||||
if (!instanceUrl || !token) return { error: 'No instance configured — add one in Settings.' };
|
|
||||||
return { instanceUrl, token };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function downloadFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
|
||||||
const creds = await resolveCredentials(db);
|
|
||||||
if ('error' in creds) return { synced: 0, total: 0, error: creds.error };
|
|
||||||
const { instanceUrl, token } = creds;
|
|
||||||
|
|
||||||
let resp: Response;
|
|
||||||
try {
|
|
||||||
resp = await fetch(`${instanceUrl}/api/feed`, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return { synced: 0, total: 0, error: 'Could not reach instance — check your connection.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resp.status === 401) return { synced: 0, total: 0, error: 'Session expired — reconnect in Settings.' };
|
|
||||||
if (!resp.ok) return { synced: 0, total: 0, error: `Server error (${resp.status})` };
|
|
||||||
|
|
||||||
const data: { activities?: RemoteSummary[] } = await resp.json();
|
|
||||||
const activities = data.activities ?? [];
|
|
||||||
const syncMode = (await getSetting(db, 'sync_mode')) ?? 'summaries';
|
|
||||||
|
|
||||||
let synced = 0;
|
|
||||||
for (const a of activities) {
|
|
||||||
const detailJson = JSON.stringify({
|
|
||||||
id: a.id,
|
|
||||||
title: a.title ?? a.id,
|
|
||||||
sport: a.sport ?? null,
|
|
||||||
started_at: a.started_at ?? null,
|
|
||||||
distance_m: a.distance_m ?? null,
|
|
||||||
moving_time_s: a.moving_time_s ?? null,
|
|
||||||
elevation_gain_m: a.elevation_gain_m ?? null,
|
|
||||||
avg_speed_kmh: a.avg_speed_kmh ?? null,
|
|
||||||
avg_hr_bpm: a.avg_hr_bpm ?? null,
|
|
||||||
avg_power_w: a.avg_power_w ?? null,
|
|
||||||
});
|
|
||||||
const changed = await upsertRemoteActivity(db, a.id, detailJson);
|
|
||||||
if (changed) synced++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (syncMode !== 'full') return { synced, total: activities.length };
|
|
||||||
|
|
||||||
// Full mode: fetch geojson + timeseries for activities missing them
|
|
||||||
const headers = { Authorization: `Bearer ${token}` };
|
|
||||||
let fetched = 0;
|
|
||||||
for (const a of activities) {
|
|
||||||
const row = db.getFirstSync<{ g: number; t: number }>(
|
|
||||||
'SELECT (geojson IS NOT NULL) as g, (timeseries_json IS NOT NULL) as t FROM activities WHERE id = ?',
|
|
||||||
[a.id],
|
|
||||||
);
|
|
||||||
if (row?.g && row?.t) continue;
|
|
||||||
|
|
||||||
let gj: string | null = null;
|
|
||||||
let ts: string | null = null;
|
|
||||||
try {
|
|
||||||
if (!row?.g) {
|
|
||||||
const r = await fetch(`${instanceUrl}/api/activity/${a.id}/geojson`, { headers });
|
|
||||||
if (r.ok) gj = await r.text();
|
|
||||||
}
|
|
||||||
if (!row?.t) {
|
|
||||||
const r = await fetch(`${instanceUrl}/api/activity/${a.id}/timeseries`, { headers });
|
|
||||||
if (r.ok) ts = await r.text();
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
if (gj !== null || ts !== null) {
|
|
||||||
await db.runAsync(
|
|
||||||
`UPDATE activities SET
|
|
||||||
geojson = COALESCE(geojson, ?),
|
|
||||||
timeseries_json = COALESCE(timeseries_json, ?)
|
|
||||||
WHERE id = ? AND origin = 'remote'`,
|
|
||||||
[gj, ts, a.id],
|
|
||||||
);
|
|
||||||
fetched++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { synced, total: activities.length, fetched };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function uploadFeed(
|
|
||||||
db: SQLiteDatabase,
|
|
||||||
onProgress?: (n: number, total: number) => void,
|
|
||||||
): Promise<SyncResult> {
|
|
||||||
const creds = await resolveCredentials(db);
|
|
||||||
if ('error' in creds) return { synced: 0, total: 0, error: creds.error };
|
|
||||||
const { instanceUrl, token } = creds;
|
|
||||||
|
|
||||||
// Reconcile local synced_at against what the server actually has.
|
|
||||||
// If the server was wiped/reset, activities we thought were uploaded need
|
|
||||||
// re-uploading — clear their synced_at so they re-enter the upload queue.
|
|
||||||
try {
|
|
||||||
const feedResp = await fetch(`${instanceUrl}/api/feed`, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
if (feedResp.ok) {
|
|
||||||
const feedData: { activities?: { id: string }[] } = await feedResp.json();
|
|
||||||
const serverIds = new Set((feedData.activities ?? []).map(a => a.id));
|
|
||||||
const syncedRows = db.getAllSync<{ id: string }>(
|
|
||||||
`SELECT id FROM activities WHERE origin = 'local' AND synced_at IS NOT NULL`,
|
|
||||||
);
|
|
||||||
for (const row of syncedRows) {
|
|
||||||
if (!serverIds.has(row.id)) {
|
|
||||||
await db.runAsync(`UPDATE activities SET synced_at = NULL WHERE id = ?`, [row.id]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Best-effort — proceed with upload even if reconciliation fails
|
|
||||||
}
|
|
||||||
|
|
||||||
const { uploaded, failed } = await uploadLocalActivities(db, instanceUrl, token, onProgress);
|
|
||||||
return { synced: 0, total: 0, uploaded, failed: failed || undefined };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
|
||||||
const dl = await downloadFeed(db);
|
|
||||||
if (dl.error) return dl;
|
|
||||||
|
|
||||||
const uploadEnabled = (await getSetting(db, 'sync_upload')) === 'true';
|
|
||||||
let uploaded = 0;
|
|
||||||
if (uploadEnabled) {
|
|
||||||
const ul = await uploadFeed(db);
|
|
||||||
uploaded = ul.uploaded ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...dl, uploaded: uploaded || undefined };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function countPendingUploads(db: SQLiteDatabase): Promise<number> {
|
|
||||||
const row = db.getFirstSync<{ n: number }>(
|
|
||||||
`SELECT COUNT(*) as n FROM activities WHERE origin = 'local' AND synced_at IS NULL`,
|
|
||||||
);
|
|
||||||
return row?.n ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadLocalActivities(
|
|
||||||
db: SQLiteDatabase,
|
|
||||||
instanceUrl: string,
|
|
||||||
token: string,
|
|
||||||
onProgress?: (n: number, total: number) => void,
|
|
||||||
): Promise<{ uploaded: number; failed: number }> {
|
|
||||||
const rows = db.getAllSync<{
|
|
||||||
id: string;
|
|
||||||
detail_json: string;
|
|
||||||
timeseries_json: string | null;
|
|
||||||
geojson: string | null;
|
|
||||||
original_path: string | null;
|
|
||||||
edits_json: string | null;
|
|
||||||
}>(
|
|
||||||
`SELECT id, detail_json, timeseries_json, geojson, original_path, edits_json
|
|
||||||
FROM activities WHERE origin = 'local' AND synced_at IS NULL`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const preferRaw = (await getSetting(db, 'upload_format') ?? 'raw') === 'raw';
|
|
||||||
const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
|
|
||||||
let uploaded = 0;
|
|
||||||
let failed = 0;
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const total = rows.length;
|
|
||||||
|
|
||||||
for (let i = 0; i < rows.length; i++) {
|
|
||||||
const row = rows[i];
|
|
||||||
onProgress?.(i + 1, total);
|
|
||||||
try {
|
|
||||||
let resp: Response;
|
|
||||||
|
|
||||||
// When preferRaw is set and the original file is still on disk, send the raw
|
|
||||||
// bytes to /api/upload/raw so the server re-extracts with DEM elevation correction.
|
|
||||||
const useRaw = preferRaw &&
|
|
||||||
row.original_path !== null &&
|
|
||||||
(await FileSystem.getInfoAsync(row.original_path)).exists;
|
|
||||||
|
|
||||||
const userTitle: string | null = row.edits_json
|
|
||||||
? (JSON.parse(row.edits_json).title ?? null)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (useRaw) {
|
|
||||||
const filename = row.original_path!.split('/').pop() ?? 'activity.fit';
|
|
||||||
const base64 = await FileSystem.readAsStringAsync(row.original_path!, {
|
|
||||||
encoding: FileSystem.EncodingType.Base64,
|
|
||||||
});
|
|
||||||
resp = await fetch(`${instanceUrl}/api/upload/raw`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify({ filename, base64, ...(userTitle ? { user_title: userTitle } : {}) }),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const detail = JSON.parse(row.detail_json);
|
|
||||||
if (userTitle) detail.title = userTitle;
|
|
||||||
const body: Record<string, unknown> = { activity: { id: row.id, ...detail } };
|
|
||||||
if (row.timeseries_json) body.timeseries = JSON.parse(row.timeseries_json);
|
|
||||||
if (row.geojson) body.geojson = JSON.parse(row.geojson);
|
|
||||||
resp = await fetch(`${instanceUrl}/api/upload/bas`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resp.ok) {
|
|
||||||
await db.runAsync(`UPDATE activities SET synced_at = ? WHERE id = ?`, [now, row.id]);
|
|
||||||
// Option A: after a raw upload, update local detail/timeseries/geojson with the
|
|
||||||
// server's DEM-corrected extraction so the app shows better elevation data.
|
|
||||||
if (useRaw) {
|
|
||||||
try {
|
|
||||||
const data = await resp.json() as {
|
|
||||||
id: string;
|
|
||||||
detail: object;
|
|
||||||
timeseries: object | null;
|
|
||||||
geojson: object | null;
|
|
||||||
source_hash: string;
|
|
||||||
};
|
|
||||||
if (data.id === row.id) {
|
|
||||||
await db.runAsync(
|
|
||||||
`UPDATE activities
|
|
||||||
SET detail_json = ?,
|
|
||||||
timeseries_json = COALESCE(?, timeseries_json),
|
|
||||||
geojson = COALESCE(?, geojson),
|
|
||||||
source_hash = ?
|
|
||||||
WHERE id = ?`,
|
|
||||||
[
|
|
||||||
JSON.stringify(data.detail),
|
|
||||||
data.timeseries ? JSON.stringify(data.timeseries) : null,
|
|
||||||
data.geojson ? JSON.stringify(data.geojson) : null,
|
|
||||||
data.source_hash,
|
|
||||||
row.id,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Non-fatal: synced_at is already set, local data stays as-is
|
|
||||||
}
|
|
||||||
}
|
|
||||||
uploaded++;
|
|
||||||
} else {
|
|
||||||
console.warn(`upload ${row.id}: HTTP ${resp.status}`);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`upload ${row.id}:`, err);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { uploaded, failed };
|
|
||||||
}
|
|
||||||
|
|
||||||
type RemoteSummary = {
|
|
||||||
id: string;
|
|
||||||
title?: string;
|
|
||||||
sport?: string;
|
|
||||||
started_at?: string;
|
|
||||||
distance_m?: number | null;
|
|
||||||
moving_time_s?: number | null;
|
|
||||||
elevation_gain_m?: number | null;
|
|
||||||
avg_speed_kmh?: number | null;
|
|
||||||
avg_hr_bpm?: number | null;
|
|
||||||
avg_power_w?: number | null;
|
|
||||||
};
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
import { StyleSheet } from 'react-native';
|
|
||||||
import WebView from 'react-native-webview';
|
|
||||||
import { handleWebViewMessage, pyodideRef } from './extractActivity';
|
|
||||||
|
|
||||||
const CDN = 'https://cdn.jsdelivr.net/pyodide/v0.26.4/full/';
|
|
||||||
// v0.18.1: last version whose JS wrapper avoids ??, ?., and other syntax
|
|
||||||
// unavailable on Chrome <80 (e.g. Karoo WebView 61). Used in the compat path.
|
|
||||||
const CDN_COMPAT = 'https://cdn.jsdelivr.net/pyodide/v0.18.1/full/';
|
|
||||||
|
|
||||||
// Python snippets embedded as JSON strings to avoid any JS/TS escaping issues.
|
|
||||||
const PY_INSTALL_PACKAGES = [
|
|
||||||
'import micropip',
|
|
||||||
'await micropip.install(["fitdecode", "gpxpy"])',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
// emfs:// is Pyodide's Emscripten-FS URL scheme — the only reliable way to
|
|
||||||
// install a wheel from bytes without an http/https URL (blob: URLs are not
|
|
||||||
// recognised by micropip and cause an InvalidRequirement parse error).
|
|
||||||
// _wheel_path is set as a Pyodide global before this runs.
|
|
||||||
const PY_INSTALL_WHEEL = [
|
|
||||||
'import micropip',
|
|
||||||
'await micropip.install("emfs://" + _wheel_path, deps=False)',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const PY_EXTRACT = [
|
|
||||||
'import json, shutil',
|
|
||||||
'from pathlib import Path',
|
|
||||||
'from bincio.extract.parsers.factory import parse_file',
|
|
||||||
'from bincio.extract.metrics import compute',
|
|
||||||
'from bincio.extract.writer import make_activity_id, write_activity',
|
|
||||||
'',
|
|
||||||
'outdir = Path("/tmp/bincio_out")',
|
|
||||||
'if outdir.exists(): shutil.rmtree(outdir)',
|
|
||||||
'outdir.mkdir()',
|
|
||||||
'',
|
|
||||||
'activity = parse_file(Path("/tmp/" + _filename))',
|
|
||||||
'metrics = compute(activity)',
|
|
||||||
'write_activity(activity, metrics, outdir, privacy="public", rdp_epsilon=0.0001)',
|
|
||||||
'act_id = make_activity_id(activity)',
|
|
||||||
'',
|
|
||||||
'detail_path = outdir / "activities" / (act_id + ".json")',
|
|
||||||
'ts_path = outdir / "activities" / (act_id + ".timeseries.json")',
|
|
||||||
'geojson_path = outdir / "activities" / (act_id + ".geojson")',
|
|
||||||
'',
|
|
||||||
'# write_activity in the installed wheel silently skips timeseries — write it directly.',
|
|
||||||
'if not ts_path.exists():',
|
|
||||||
' from bincio.extract.timeseries import build_timeseries as _bts',
|
|
||||||
' _ts = _bts(activity.points, activity.started_at, "public")',
|
|
||||||
' if _ts.get("t"):',
|
|
||||||
' ts_path.write_text(json.dumps(_ts))',
|
|
||||||
'',
|
|
||||||
'json.dumps({',
|
|
||||||
' "id": act_id,',
|
|
||||||
' "detail": json.loads(detail_path.read_text()),',
|
|
||||||
' "timeseries": json.loads(ts_path.read_text()) if ts_path.exists() else None,',
|
|
||||||
' "geojson": json.loads(geojson_path.read_text()) if geojson_path.exists() else None,',
|
|
||||||
'})',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
// JSON.stringify gives us safely-quoted JS string literals for embedding in HTML.
|
|
||||||
const PYODIDE_HTML = `<!DOCTYPE html>
|
|
||||||
<html><head><meta charset="utf-8"></head>
|
|
||||||
<body>
|
|
||||||
<script>
|
|
||||||
var _PY_INSTALL_PACKAGES = ${JSON.stringify(PY_INSTALL_PACKAGES)};
|
|
||||||
var _PY_INSTALL_WHEEL = ${JSON.stringify(PY_INSTALL_WHEEL)};
|
|
||||||
var _PY_EXTRACT = ${JSON.stringify(PY_EXTRACT)};
|
|
||||||
var _CDN = ${JSON.stringify(CDN)};
|
|
||||||
var _CDN_COMPAT = ${JSON.stringify(CDN_COMPAT)};
|
|
||||||
|
|
||||||
function _post(m) { window.ReactNativeWebView.postMessage(JSON.stringify(m)); }
|
|
||||||
|
|
||||||
var pyodide = null;
|
|
||||||
var packagesReady = false;
|
|
||||||
var wheelReady = false;
|
|
||||||
var initError = null;
|
|
||||||
|
|
||||||
(async function init() {
|
|
||||||
try {
|
|
||||||
// WebAssembly.Global was added in Chrome 69. Without it Pyodide cannot
|
|
||||||
// initialise on any version. Bail out immediately so the mobile app can
|
|
||||||
// fall back to server-side extraction without attempting a 35 MB download.
|
|
||||||
if (typeof WebAssembly === 'undefined' || typeof WebAssembly.Global === 'undefined') {
|
|
||||||
_post({ type: 'engine_unavailable', reason: 'wasm_global' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_post({ type: 'progress', msg: 'Loading Python runtime…' });
|
|
||||||
|
|
||||||
// Chrome <80 is missing features that modern Pyodide uses in its JS wrapper:
|
|
||||||
// Chrome <71: no globalThis → factory throws ReferenceError immediately
|
|
||||||
// Chrome <63: no dynamic import() / for-await-of → parse/runtime failure
|
|
||||||
// Detection: read Chrome version from UA; absent means non-Chrome (assume modern).
|
|
||||||
var _chromeVer = (navigator.userAgent.match(/Chrome\\/([0-9]+)/) || [])[1];
|
|
||||||
var _needsPatch = _chromeVer && parseInt(_chromeVer) < 80;
|
|
||||||
|
|
||||||
if (_needsPatch) {
|
|
||||||
// Use v0.18.1 — its JS wrapper avoids ??, ?., and other Chrome-80+ syntax.
|
|
||||||
// Then apply three text patches before injecting via Blob URL (Blob scripts
|
|
||||||
// bypass the browser's module pre-scanner, so patched keywords are invisible).
|
|
||||||
//
|
|
||||||
// Patches (split/join avoids regex escapes, which template literals corrupt):
|
|
||||||
// 1. globalThis polyfill prepended — Chrome <71 lacks globalThis entirely
|
|
||||||
// 2. import( → __loadScript( — Chrome <63 cannot parse dynamic import
|
|
||||||
// 3. for await( → for( — Chrome <63 lacks async iteration;
|
|
||||||
// the only affected fn (getFsHandles/NativeFS) is never called by us
|
|
||||||
window.__loadScript = function(url) {
|
|
||||||
return new Promise(function(res, rej) {
|
|
||||||
var s = document.createElement('script');
|
|
||||||
s.src = url;
|
|
||||||
s.onload = res;
|
|
||||||
s.onerror = function() { rej(new Error('Failed to load ' + url)); };
|
|
||||||
document.head.appendChild(s);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
var _pyResp = await fetch(_CDN_COMPAT + 'pyodide.js');
|
|
||||||
if (!_pyResp.ok) throw new Error('Could not fetch pyodide.js (' + _pyResp.status + ')');
|
|
||||||
var _pyCode = await _pyResp.text();
|
|
||||||
_pyCode = 'var globalThis=typeof globalThis!=="undefined"?globalThis:self;\\n' + _pyCode;
|
|
||||||
_pyCode = _pyCode.split('import(').join('__loadScript(');
|
|
||||||
_pyCode = _pyCode.split('for await(').join('for(');
|
|
||||||
await new Promise(function(res, rej) {
|
|
||||||
var blob = new Blob([_pyCode], { type: 'application/javascript' });
|
|
||||||
var blobUrl = URL.createObjectURL(blob);
|
|
||||||
var s = document.createElement('script');
|
|
||||||
s.src = blobUrl;
|
|
||||||
s.onload = function() { URL.revokeObjectURL(blobUrl); res(); };
|
|
||||||
s.onerror = function() { URL.revokeObjectURL(blobUrl); rej(new Error('Failed to inject patched pyodide.js')); };
|
|
||||||
document.head.appendChild(s);
|
|
||||||
});
|
|
||||||
pyodide = await loadPyodide({ indexURL: _CDN_COMPAT });
|
|
||||||
} else {
|
|
||||||
await new Promise(function(res, rej) {
|
|
||||||
var s = document.createElement('script');
|
|
||||||
s.src = _CDN + 'pyodide.js';
|
|
||||||
s.onload = res; s.onerror = rej;
|
|
||||||
document.head.appendChild(s);
|
|
||||||
});
|
|
||||||
pyodide = await loadPyodide({ indexURL: _CDN });
|
|
||||||
}
|
|
||||||
|
|
||||||
_post({ type: 'progress', msg: 'Loading packages…' });
|
|
||||||
await pyodide.loadPackage(['lxml', 'pyyaml', 'micropip']);
|
|
||||||
|
|
||||||
_post({ type: 'progress', msg: 'Installing fitdecode, gpxpy…' });
|
|
||||||
await pyodide.runPythonAsync(_PY_INSTALL_PACKAGES);
|
|
||||||
|
|
||||||
packagesReady = true;
|
|
||||||
_post({ type: 'pyodide_ready' });
|
|
||||||
} catch(e) {
|
|
||||||
initError = String(e);
|
|
||||||
_post({ type: 'init_error', message: initError });
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
window._bincioExtract = async function(params) {
|
|
||||||
var reqId = params.reqId;
|
|
||||||
var filename = params.filename;
|
|
||||||
var base64 = params.base64;
|
|
||||||
var wheelBase64 = params.wheelBase64; // pre-fetched by React Native (avoids ATS/HTTP issues)
|
|
||||||
var wheelFilename = params.wheelFilename; // e.g. "bincio-0.1.0-py3-none-any.whl"
|
|
||||||
|
|
||||||
function post(m) { _post(Object.assign({}, m, { reqId: reqId })); }
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Wait for base packages if still loading
|
|
||||||
if (!packagesReady && !initError) {
|
|
||||||
await new Promise(function(res, rej) {
|
|
||||||
var n = 0;
|
|
||||||
var id = setInterval(function() {
|
|
||||||
if (packagesReady) { clearInterval(id); res(undefined); }
|
|
||||||
else if (initError) { clearInterval(id); rej(new Error(initError)); }
|
|
||||||
else if (++n > 300) { clearInterval(id); rej(new Error('Pyodide init timed out')); }
|
|
||||||
}, 200);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (initError) throw new Error(initError);
|
|
||||||
|
|
||||||
// Install bincio wheel on first extraction.
|
|
||||||
// Wheel bytes arrive pre-fetched from React Native (avoids ATS/HTTP issues).
|
|
||||||
// Write to Pyodide's Emscripten FS so micropip can install via emfs:// URL
|
|
||||||
// (blob: URLs are not recognised by micropip — they cause an InvalidRequirement error).
|
|
||||||
if (!wheelReady) {
|
|
||||||
post({ type: 'progress', msg: 'Loading Bincio…' });
|
|
||||||
var wheelBytes = Uint8Array.from(atob(wheelBase64), function(c) { return c.charCodeAt(0); });
|
|
||||||
var wheelPath = '/tmp/' + wheelFilename;
|
|
||||||
pyodide.FS.writeFile(wheelPath, wheelBytes);
|
|
||||||
pyodide.globals.set('_wheel_path', wheelPath);
|
|
||||||
await pyodide.runPythonAsync(_PY_INSTALL_WHEEL);
|
|
||||||
wheelReady = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
post({ type: 'progress', msg: 'Extracting…' });
|
|
||||||
|
|
||||||
// Decode base64 file bytes and write to Pyodide's virtual filesystem
|
|
||||||
var bytes = Uint8Array.from(atob(base64), function(c) { return c.charCodeAt(0); });
|
|
||||||
pyodide.FS.writeFile('/tmp/' + filename, bytes);
|
|
||||||
|
|
||||||
// SHA-256 of original file bytes (replaces the stub source_hash)
|
|
||||||
var hashBuf = await crypto.subtle.digest('SHA-256', bytes.buffer);
|
|
||||||
var sourceHash = Array.from(new Uint8Array(hashBuf))
|
|
||||||
.map(function(b) { return b.toString(16).padStart(2, '0'); })
|
|
||||||
.join('');
|
|
||||||
|
|
||||||
// Run the bincio extraction pipeline
|
|
||||||
pyodide.globals.set('_filename', filename);
|
|
||||||
var resultJson = await pyodide.runPythonAsync(_PY_EXTRACT);
|
|
||||||
var result = JSON.parse(resultJson);
|
|
||||||
|
|
||||||
_post({
|
|
||||||
type: 'result',
|
|
||||||
reqId: reqId,
|
|
||||||
id: result.id,
|
|
||||||
detail: result.detail,
|
|
||||||
timeseries: result.timeseries,
|
|
||||||
geojson: result.geojson,
|
|
||||||
sourceHash: sourceHash,
|
|
||||||
});
|
|
||||||
} catch(e) {
|
|
||||||
_post({ type: 'error', reqId: reqId, message: e.message || String(e) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
</body></html>`;
|
|
||||||
|
|
||||||
export function PyodideWebView() {
|
|
||||||
return (
|
|
||||||
<WebView
|
|
||||||
ref={pyodideRef}
|
|
||||||
source={{ html: PYODIDE_HTML, baseUrl: 'https://localhost' }}
|
|
||||||
style={styles.hidden}
|
|
||||||
onMessage={handleWebViewMessage}
|
|
||||||
javaScriptEnabled
|
|
||||||
originWhitelist={['*']}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
// Off-screen but still rendered — display:none / opacity:0 can suppress JS on some platforms.
|
|
||||||
hidden: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: -2000,
|
|
||||||
left: 0,
|
|
||||||
width: 1,
|
|
||||||
height: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import { createRef } from 'react';
|
|
||||||
import { Platform } from 'react-native';
|
|
||||||
import type WebView from 'react-native-webview';
|
|
||||||
import type { WebViewMessageEvent } from 'react-native-webview';
|
|
||||||
|
|
||||||
export type ExtractionResult = {
|
|
||||||
id: string;
|
|
||||||
detail: object;
|
|
||||||
timeseries: object | null;
|
|
||||||
geojson: object | null;
|
|
||||||
sourceHash: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Pending = {
|
|
||||||
resolve: (r: ExtractionResult) => void;
|
|
||||||
reject: (e: Error) => void;
|
|
||||||
onStatus: (msg: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const pyodideRef = createRef<WebView>();
|
|
||||||
|
|
||||||
const pending = new Map<string, Pending>();
|
|
||||||
let reqCounter = 0;
|
|
||||||
let isExtracting = false;
|
|
||||||
|
|
||||||
// Engine readiness — tracked so callers can wait before batching files.
|
|
||||||
let _engineReady = false;
|
|
||||||
let _engineError: string | null = null;
|
|
||||||
// Android <29 (API 27 = Android 8.1, e.g. Karoo) ships with a system WebView
|
|
||||||
// (Chrome <69) that lacks WebAssembly.Global, so Pyodide cannot run. Mounting
|
|
||||||
// a WebView on those devices also causes GPU driver crashes (SurfaceView
|
|
||||||
// conflicts). Skip the engine entirely and route to server extraction instead.
|
|
||||||
let _engineUnavailable = Platform.OS === 'android' && (Platform.Version as number) < 29;
|
|
||||||
const _engineResolvers: Array<() => void> = [];
|
|
||||||
const _engineRejecters: Array<(e: Error) => void> = [];
|
|
||||||
|
|
||||||
// Init-phase progress listeners (messages sent before any extraction starts).
|
|
||||||
const _progressListeners = new Set<(msg: string) => void>();
|
|
||||||
export function onEngineProgress(cb: (msg: string) => void): () => void {
|
|
||||||
_progressListeners.add(cb);
|
|
||||||
return () => _progressListeners.delete(cb);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isEngineAvailable(): boolean | null {
|
|
||||||
// null = not yet determined; true = ready; false = unavailable
|
|
||||||
if (_engineReady) return true;
|
|
||||||
if (_engineUnavailable || _engineError) return false;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function waitForEngine(timeoutMs = 300_000): Promise<void> {
|
|
||||||
if (_engineReady) return Promise.resolve();
|
|
||||||
if (_engineUnavailable) return Promise.reject(new Error('engine_unavailable'));
|
|
||||||
if (_engineError) return Promise.reject(new Error(_engineError));
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
reject(new Error('Extraction engine timed out — check network and Bincio instance URL'));
|
|
||||||
}, timeoutMs);
|
|
||||||
_engineResolvers.push(() => { clearTimeout(timer); resolve(); });
|
|
||||||
_engineRejecters.push((e) => { clearTimeout(timer); reject(e); });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function handleWebViewMessage(e: WebViewMessageEvent): void {
|
|
||||||
let msg: Record<string, unknown>;
|
|
||||||
try { msg = JSON.parse(e.nativeEvent.data); } catch { return; }
|
|
||||||
|
|
||||||
const reqId = msg.reqId as string | undefined;
|
|
||||||
const p = reqId ? pending.get(reqId) : undefined;
|
|
||||||
|
|
||||||
switch (msg.type) {
|
|
||||||
case 'pyodide_ready':
|
|
||||||
_engineReady = true;
|
|
||||||
_engineResolvers.splice(0).forEach(fn => fn());
|
|
||||||
break;
|
|
||||||
case 'engine_unavailable':
|
|
||||||
_engineUnavailable = true;
|
|
||||||
_engineRejecters.splice(0).forEach(fn => fn(new Error('engine_unavailable')));
|
|
||||||
break;
|
|
||||||
case 'init_error':
|
|
||||||
_engineError = msg.message as string;
|
|
||||||
_engineRejecters.splice(0).forEach(fn => fn(new Error(_engineError!)));
|
|
||||||
break;
|
|
||||||
case 'result':
|
|
||||||
if (p) {
|
|
||||||
pending.delete(reqId!);
|
|
||||||
p.resolve({
|
|
||||||
id: msg.id as string,
|
|
||||||
detail: msg.detail as object,
|
|
||||||
timeseries: (msg.timeseries as object | null) ?? null,
|
|
||||||
geojson: (msg.geojson as object | null) ?? null,
|
|
||||||
sourceHash: msg.sourceHash as string,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'error':
|
|
||||||
if (p) {
|
|
||||||
pending.delete(reqId!);
|
|
||||||
p.reject(new Error(msg.message as string));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'progress':
|
|
||||||
if (p) {
|
|
||||||
p.onStatus(msg.msg as string);
|
|
||||||
} else {
|
|
||||||
_progressListeners.forEach(fn => fn(msg.msg as string));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// wheelBase64 is the bincio .whl file pre-fetched by the React Native side
|
|
||||||
// (native networking supports HTTP on local network; WKWebView does not).
|
|
||||||
export function extractFile(
|
|
||||||
filename: string,
|
|
||||||
base64: string,
|
|
||||||
wheelBase64: string,
|
|
||||||
wheelFilename: string,
|
|
||||||
onStatus: (msg: string) => void = () => {},
|
|
||||||
): Promise<ExtractionResult> {
|
|
||||||
if (isExtracting) return Promise.reject(new Error('Another extraction is already in progress'));
|
|
||||||
|
|
||||||
const webview = pyodideRef.current;
|
|
||||||
if (!webview) return Promise.reject(new Error('Extraction engine not ready — restart the app'));
|
|
||||||
|
|
||||||
isExtracting = true;
|
|
||||||
const reqId = String(++reqCounter);
|
|
||||||
const args = JSON.stringify({ reqId, filename, base64, wheelBase64, wheelFilename });
|
|
||||||
|
|
||||||
return new Promise<ExtractionResult>((resolve, reject) => {
|
|
||||||
pending.set(reqId, {
|
|
||||||
resolve: (r) => { isExtracting = false; resolve(r); },
|
|
||||||
reject: (e) => { isExtracting = false; reject(e); },
|
|
||||||
onStatus,
|
|
||||||
});
|
|
||||||
webview.injectJavaScript(`window._bincioExtract(${args}); true;`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import type { ExtractionResult } from './extractActivity';
|
|
||||||
|
|
||||||
export async function checkServerAuth(instanceUrl: string, token: string): Promise<void> {
|
|
||||||
let resp: Response;
|
|
||||||
try {
|
|
||||||
resp = await fetch(`${instanceUrl}/api/feed`, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
throw new Error('Could not reach Bincio instance — check your connection.');
|
|
||||||
}
|
|
||||||
if (resp.status === 401) throw new Error('Session expired — reconnect in Settings.');
|
|
||||||
if (!resp.ok) throw new Error(`Server error (${resp.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function extractFileViaServer(
|
|
||||||
filename: string,
|
|
||||||
base64: string,
|
|
||||||
instanceUrl: string,
|
|
||||||
token: string,
|
|
||||||
onStatus: (msg: string) => void = () => {},
|
|
||||||
): Promise<ExtractionResult> {
|
|
||||||
onStatus('Uploading to Bincio instance…');
|
|
||||||
|
|
||||||
let resp: Response;
|
|
||||||
try {
|
|
||||||
resp = await fetch(`${instanceUrl}/api/upload/raw`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ filename, base64 }),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
throw new Error('Could not reach Bincio instance — check your connection.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resp.status === 401) throw new Error('Session expired — reconnect in Settings.');
|
|
||||||
if (resp.status === 422) {
|
|
||||||
const body = await resp.json().catch(() => ({})) as { detail?: string };
|
|
||||||
throw new Error(body.detail ?? 'Server could not process this file.');
|
|
||||||
}
|
|
||||||
if (!resp.ok) throw new Error(`Server error (${resp.status})`);
|
|
||||||
|
|
||||||
onStatus('Processing on server…');
|
|
||||||
const data = await resp.json() as {
|
|
||||||
ok: boolean;
|
|
||||||
id: string;
|
|
||||||
detail: object;
|
|
||||||
timeseries: object | null;
|
|
||||||
geojson: object | null;
|
|
||||||
source_hash: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: data.id,
|
|
||||||
detail: data.detail,
|
|
||||||
timeseries: data.timeseries,
|
|
||||||
geojson: data.geojson,
|
|
||||||
sourceHash: data.source_hash,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
const { getDefaultConfig } = require('expo/metro-config');
|
|
||||||
module.exports = getDefaultConfig(__dirname);
|
|
||||||
Generated
-9819
File diff suppressed because it is too large
Load Diff
@@ -1,40 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "bincio",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"main": "expo-router/entry",
|
|
||||||
"scripts": {
|
|
||||||
"start": "expo start",
|
|
||||||
"android": "expo run:android",
|
|
||||||
"ios": "expo run:ios",
|
|
||||||
"lint": "expo lint"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@maplibre/maplibre-react-native": "~11.0.0",
|
|
||||||
"expo": "~54.0.33",
|
|
||||||
"expo-background-fetch": "~14.0.9",
|
|
||||||
"expo-constants": "~18.0.13",
|
|
||||||
"expo-document-picker": "~14.0.8",
|
|
||||||
"expo-file-system": "~19.0.21",
|
|
||||||
"expo-linking": "~8.0.11",
|
|
||||||
"expo-notifications": "~0.32.16",
|
|
||||||
"expo-router": "~6.0.23",
|
|
||||||
"expo-splash-screen": "~31.0.13",
|
|
||||||
"expo-sqlite": "~16.0.10",
|
|
||||||
"expo-status-bar": "~3.0.9",
|
|
||||||
"expo-system-ui": "~6.0.9",
|
|
||||||
"expo-task-manager": "~14.0.9",
|
|
||||||
"react": "19.1.0",
|
|
||||||
"react-dom": "19.1.0",
|
|
||||||
"react-native": "0.81.5",
|
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
|
||||||
"react-native-screens": "~4.16.0",
|
|
||||||
"react-native-svg": "~15.15.0",
|
|
||||||
"react-native-webview": "13.15.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@babel/core": "^7.25.0",
|
|
||||||
"@types/react": "~19.1.0",
|
|
||||||
"typescript": "~5.9.2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-104
@@ -1,104 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Bincio mobile app — one-time setup
|
|
||||||
# Run from the mobile/ directory: ./setup.sh
|
|
||||||
# Or from the repo root: bash mobile/setup.sh
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
cd "$SCRIPT_DIR"
|
|
||||||
|
|
||||||
# ── Colours ───────────────────────────────────────────────────────────────────
|
|
||||||
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; RESET='\033[0m'
|
|
||||||
ok() { echo -e "${GREEN}✓${RESET} $*"; }
|
|
||||||
warn() { echo -e "${YELLOW}⚠${RESET} $*"; }
|
|
||||||
die() { echo -e "${RED}✗${RESET} $*" >&2; exit 1; }
|
|
||||||
step() { echo -e "\n${YELLOW}▸${RESET} $*"; }
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo " Bincio mobile setup"
|
|
||||||
echo " ═══════════════════"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ── 1. Node.js ────────────────────────────────────────────────────────────────
|
|
||||||
step "Checking Node.js..."
|
|
||||||
if ! command -v node &>/dev/null; then
|
|
||||||
die "Node.js not found. Install from https://nodejs.org (v20+ recommended)."
|
|
||||||
fi
|
|
||||||
NODE_MAJOR=$(node -v | sed 's/v//' | cut -d. -f1)
|
|
||||||
if [ "$NODE_MAJOR" -lt 18 ]; then
|
|
||||||
die "Node.js 18+ required (found $(node -v)). Update at https://nodejs.org"
|
|
||||||
fi
|
|
||||||
ok "Node.js $(node -v)"
|
|
||||||
|
|
||||||
# ── 2. npm ────────────────────────────────────────────────────────────────────
|
|
||||||
if ! command -v npm &>/dev/null; then
|
|
||||||
die "npm not found. It ships with Node.js — check your installation."
|
|
||||||
fi
|
|
||||||
ok "npm $(npm -v)"
|
|
||||||
|
|
||||||
# ── 3. Expo CLI (global, optional — we use npx) ───────────────────────────────
|
|
||||||
step "Checking Expo CLI..."
|
|
||||||
if command -v expo &>/dev/null; then
|
|
||||||
ok "Expo CLI $(expo --version) (global)"
|
|
||||||
else
|
|
||||||
warn "Expo CLI not installed globally. Using npx instead (slightly slower)."
|
|
||||||
warn "Install globally with: npm install -g expo-cli"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 4. Platform tools ─────────────────────────────────────────────────────────
|
|
||||||
step "Checking platform tools..."
|
|
||||||
PLATFORM="$(uname -s)"
|
|
||||||
|
|
||||||
if [ "$PLATFORM" = "Darwin" ]; then
|
|
||||||
if command -v xcodebuild &>/dev/null; then
|
|
||||||
ok "Xcode $(xcodebuild -version 2>/dev/null | head -1 | awk '{print $2}')"
|
|
||||||
else
|
|
||||||
warn "Xcode not found — iOS builds will not work."
|
|
||||||
warn "Install Xcode from the App Store, then: xcode-select --install"
|
|
||||||
fi
|
|
||||||
if command -v xcrun &>/dev/null && xcrun --sdk iphoneos --show-sdk-version &>/dev/null; then
|
|
||||||
ok "iOS SDK available"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if command -v adb &>/dev/null; then
|
|
||||||
ok "Android SDK / adb found"
|
|
||||||
else
|
|
||||||
warn "adb not found — Android builds require Android Studio."
|
|
||||||
warn "Install from https://developer.android.com/studio"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 5. Install dependencies ───────────────────────────────────────────────────
|
|
||||||
step "Installing npm dependencies..."
|
|
||||||
if [ -d node_modules ] && [ -f node_modules/.package-lock.json ]; then
|
|
||||||
ok "node_modules already present — running npm install to sync..."
|
|
||||||
fi
|
|
||||||
npm install
|
|
||||||
ok "Dependencies installed"
|
|
||||||
|
|
||||||
# ── 6. expo-env.d.ts (required by expo-router) ────────────────────────────────
|
|
||||||
step "Generating Expo type declarations..."
|
|
||||||
npx expo customize expo-env.d.ts --no-install 2>/dev/null || true
|
|
||||||
if [ ! -f expo-env.d.ts ]; then
|
|
||||||
echo '/// <reference types="expo-router/types" />' > expo-env.d.ts
|
|
||||||
fi
|
|
||||||
ok "expo-env.d.ts ready"
|
|
||||||
|
|
||||||
# ── 7. Summary ────────────────────────────────────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo " ══════════════════════════════════════════"
|
|
||||||
echo " Setup complete! Next steps:"
|
|
||||||
echo ""
|
|
||||||
echo " Start with Expo Go (scan QR on your phone):"
|
|
||||||
echo " npx expo start"
|
|
||||||
echo ""
|
|
||||||
echo " Run on Android emulator:"
|
|
||||||
echo " npx expo run:android"
|
|
||||||
echo ""
|
|
||||||
echo " Run on iOS simulator (macOS only):"
|
|
||||||
echo " npx expo run:ios"
|
|
||||||
echo ""
|
|
||||||
echo " Build APK for Karoo sideload:"
|
|
||||||
echo " npx eas build -p android --profile preview"
|
|
||||||
echo " ══════════════════════════════════════════"
|
|
||||||
echo ""
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
export type PaletteKey = 'auto' | 'default' | 'giro' | 'tour' | 'vuelta';
|
|
||||||
|
|
||||||
export const PALETTES = {
|
|
||||||
default: { accent: '#60a5fa', dim: 'rgba(96,165,250,0.15)', label: 'Default' },
|
|
||||||
giro: { accent: '#f472b6', dim: 'rgba(244,114,182,0.15)', label: "Giro d'Italia" },
|
|
||||||
tour: { accent: '#facc15', dim: 'rgba(250,204,21,0.15)', label: 'Tour de France' },
|
|
||||||
vuelta: { accent: '#ef4444', dim: 'rgba(239,68,68,0.15)', label: 'Vuelta a España' },
|
|
||||||
} as const satisfies Record<string, { accent: string; dim: string; label: string }>;
|
|
||||||
|
|
||||||
export type Theme = (typeof PALETTES)[keyof typeof PALETTES];
|
|
||||||
|
|
||||||
// Race windows [month 0-indexed, day inclusive] — update each year
|
|
||||||
const RACES: Array<{ key: Exclude<PaletteKey, 'auto' | 'default'>; start: [number, number]; end: [number, number] }> = [
|
|
||||||
{ key: 'giro', start: [4, 8], end: [5, 1] }, // May 8 – Jun 1
|
|
||||||
{ key: 'tour', start: [5, 27], end: [6, 19] }, // Jun 27 – Jul 19
|
|
||||||
{ key: 'vuelta', start: [7, 15], end: [8, 6] }, // Aug 15 – Sep 6
|
|
||||||
];
|
|
||||||
|
|
||||||
export function autoKey(): Exclude<PaletteKey, 'auto'> {
|
|
||||||
const now = new Date();
|
|
||||||
const y = now.getFullYear();
|
|
||||||
for (const r of RACES) {
|
|
||||||
const start = new Date(y, r.start[0], r.start[1]);
|
|
||||||
const end = new Date(y, r.end[0], r.end[1] + 1);
|
|
||||||
if (now >= start && now < end) return r.key;
|
|
||||||
}
|
|
||||||
return 'default';
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "expo/tsconfig.base",
|
|
||||||
"compilerOptions": {
|
|
||||||
"strict": true,
|
|
||||||
"paths": {
|
|
||||||
"@/*": [
|
|
||||||
"./*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"**/*.ts",
|
|
||||||
"**/*.tsx",
|
|
||||||
".expo/types/**/*.d.ts"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user