Compare commits

...
9 Commits
90 changed files with 11734 additions and 4602 deletions
@@ -0,0 +1,3 @@
- [ ] Configure `gradle.user.home` and related properties in `gradle.properties` [ ]
- [ ] Verify Gradle Sync and Build [ ]
- [ ] Create Walkthrough [ ]
@@ -0,0 +1,37 @@
# Plan de Configuración de Relación de Aspecto de Fotos
Este plan detalla la adición de una configuración de relación de aspecto para las fotos capturadas con la cámara de la aplicación.
## User Review Required
> [!IMPORTANT]
> La relación de aspecto afectará tanto a la previsualización en pantalla como al archivo de imagen resultante. Algunas relaciones de aspecto (como 1:1 o 16:9) pueden implicar un recorte del sensor original de la cámara.
## Proposed Changes
### [Configuración y Persistencia]
#### [MODIFY] [footerConfig.ts](file:///C:/xampp/htdocs/Avante%20movil/src/photo/footerConfig.ts)
- Definir el tipo `PhotoAspectRatio = '1:1' | '4:3' | '16:9' | 'full'`.
- Añadir la propiedad `aspectRatio` a la interfaz `FooterConfig`.
- Actualizar `defaultConfig()` para incluir `aspectRatio: '4:3'` por defecto.
### [Interfaz de Usuario]
#### [MODIFY] [PhotoSettingsScreen.tsx](file:///C:/xampp/htdocs/Avante%20movil/src/screens/PhotoSettingsScreen.tsx)
- Añadir un selector (`ChipSelect`) para la relación de aspecto antes del selector de calidad de compresión.
- Definir las opciones visibles para el usuario.
### [Cámara]
#### [MODIFY] [CameraScreen.tsx](file:///C:/xampp/htdocs/Avante%20movil/src/screens/CameraScreen.tsx)
- Recuperar la configuración de `aspectRatio` desde el almacenamiento.
- Aplicar la relación de aspecto al componente `CameraView` de `expo-camera`.
- Ajustar el estilo del contenedor de previsualización para que refleje visualmente la proporción elegida (p. ej. añadiendo bandas negras si es 1:1 o 16:9 en una pantalla que no coincide).
## Verification Plan
### Manual Verification
1. **Configuración**: Cambiar la relación de aspecto en la pantalla de configuración y verificar que se guarda correctamente.
2. **Cámara**: Abrir la cámara y comprobar que el área de visión cambia según la proporción elegida (1:1 debería verse cuadrado).
3. **Captura**: Tomar una foto y verificar en la galería local de la app que las dimensiones de la imagen resultante coinciden con la proporción configurada.
@@ -0,0 +1,10 @@
# Tareas de Configuración de Relación de Aspecto
- `[x]` Configuración y Persistencia
- `[x]` Definir `PhotoAspectRatio` y añadir `aspectRatio` a `FooterConfig` en `footerConfig.ts`
- `[x]` Interfaz de Usuario
- `[x]` Añadir selector de proporción en `PhotoSettingsScreen.tsx`
- `[x]` Cámara
- `[x]` Aplicar proporción al visor y captura en `CameraScreen.tsx`
- `[x]` Verificación
- `[x]` Probar proporciones 1:1, 4:3, 16:9 y full
@@ -0,0 +1,28 @@
# Mejora: Relación de Aspecto en Fotos
Se ha añadido la posibilidad de elegir la proporción de las fotografías capturadas, permitiendo mayor flexibilidad según las necesidades de la obra.
## Cambios Realizados
### 1. Nueva Configuración
En la pantalla de **Configuración de fotos**, ahora encontrarás una opción para elegir la **Relación de aspecto**:
- **1:1**: Fotos cuadradas, ideales para detalles centrados.
- **4:3**: Formato clásico de fotografía.
- **16:9**: Formato panorámico, útil para vistas generales de la obra.
- **Pantalla completa**: Aprovecha todo el sensor y la pantalla disponible.
### 2. Visor Adaptativo
La cámara ahora ajusta su máscara visual en tiempo real:
- Si eliges **1:1**, el visor se volverá cuadrado, mostrándote exactamente qué se incluirá en la captura final.
- Se han optimizado los ratios de hardware (4:3 y 16:9) para minimizar el estiramiento o deformación en diferentes dispositivos.
### 3. Persistencia
La elección se guarda localmente y se aplica automáticamente cada vez que abras la cámara, sin necesidad de reconfigurarla.
## Verificación
- [x] **Configuración**: El selector guarda y recupera correctamente el valor.
- [x] **Visor**: El área de captura cambia visualmente al alternar entre ratios.
- [x] **Cámara**: Se utiliza el ratio de hardware más cercano al deseado para evitar distorsiones.
> [!TIP]
> Para fotos de inspección rápidas, el formato **1:1** es muy útil para asegurar que el objeto de interés queda centrado y no se pierde en los bordes.
-1
View File
@@ -7,7 +7,6 @@ node_modules/
.expo/ .expo/
dist/ dist/
web-build/ web-build/
expo-env.d.ts
# Native # Native
.kotlin/ .kotlin/
+10 -1
View File
@@ -1,5 +1,7 @@
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { Platform } from 'react-native';
import * as NavigationBar from 'expo-navigation-bar';
import { SafeAreaProvider } from 'react-native-safe-area-context'; import { SafeAreaProvider } from 'react-native-safe-area-context';
import { SessionProvider } from './src/auth/session'; import { SessionProvider } from './src/auth/session';
import { getDb } from './src/db/database'; import { getDb } from './src/db/database';
@@ -9,13 +11,20 @@ export default function App() {
// Abre y migra la BD local al arrancar. // Abre y migra la BD local al arrancar.
useEffect(() => { useEffect(() => {
void getDb(); void getDb();
// Configuración global de la barra de navegación en Android (Edge-to-Edge)
if (Platform.OS === 'android') {
void NavigationBar.setPositionAsync('absolute');
void NavigationBar.setBackgroundColorAsync('#ffffff00'); // Transparente
void NavigationBar.setButtonStyleAsync('dark'); // Iconos oscuros por defecto
}
}, []); }, []);
return ( return (
<SafeAreaProvider> <SafeAreaProvider>
<SessionProvider> <SessionProvider>
<RootNavigator /> <RootNavigator />
<StatusBar style="light" /> <StatusBar style="auto" />
</SessionProvider> </SessionProvider>
</SafeAreaProvider> </SafeAreaProvider>
); );
-3
View File
@@ -12,8 +12,5 @@ local.properties
*.hprof *.hprof
.cxx/ .cxx/
# generated inline modules
app/src/main/java/inline/
# Bundle artifacts # Bundle artifacts
*.jsbundle *.jsbundle
+47 -28
View File
@@ -4,6 +4,27 @@ apply plugin: "com.facebook.react"
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath() def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
static def versionToNumber(major, minor, patch) {
return patch * 100 + minor * 10000 + major * 1000000
}
def getRNVersion() {
def version = providers.exec {
workingDir(projectDir)
commandLine("node", "-e", "console.log(require('react-native/package.json').version);")
}.standardOutput.asText.get().trim()
def coreVersion = version.split("-")[0]
def (major, minor, patch) = coreVersion.tokenize('.').collect { it.toInteger() }
return versionToNumber(
major,
minor,
patch
)
}
def rnVersion = getRNVersion()
/** /**
* This is the configuration block to customize your React Native Android app. * 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. * By default you don't need to apply any configuration, just uncomment the lines you need.
@@ -11,22 +32,21 @@ def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
react { react {
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim()) 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() 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('hermes-compiler/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/hermesc/%OS-BIN%/hermesc" 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() 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 // Use Expo CLI to bundle the app, this ensures the Metro config
// works correctly with Expo projects. // 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()) cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
bundleCommand = "export:embed" bundleCommand = "export:embed"
/* Folders */ /* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '../..' // The root of your project, i.e. where "package.json" lives. Default is '..'
// root = file("../../") // root = file("../")
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native // The folder where the react-native NPM package is. Default is ../node_modules/react-native
// reactNativeDir = file("../../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 // The folder where the react-native Codegen package is. Default is ../node_modules/@react-native/codegen
// codegenDir = file("../../node_modules/@react-native/codegen") // codegenDir = file("../node_modules/@react-native/codegen")
/* Variants */ /* Variants */
// The list of variants to that are debuggable. For those we're going to // The list of variants to that are debuggable. For those we're going to
@@ -59,14 +79,16 @@ react {
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map" // The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
// hermesFlags = ["-O", "-output-source-map"] // hermesFlags = ["-O", "-output-source-map"]
/* Autolinking */ if (rnVersion >= versionToNumber(0, 75, 0)) {
autolinkLibrariesWithApp() /* 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). * Set this to true to Run Proguard on Release builds to minify the Java bytecode.
*/ */
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean() def enableProguardInReleaseBuilds = (findProperty('android.enableProguardInReleaseBuilds') ?: false).toBoolean()
/** /**
* The preferred build flavor of JavaScriptCore (JSC) * The preferred build flavor of JavaScriptCore (JSC)
@@ -79,7 +101,7 @@ def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBu
* give correct results when using with locales other than en-US. Note that * give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default. * this variant is about 6MiB larger per architecture than default.
*/ */
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+' def jscFlavor = 'org.webkit:android-jsc:+'
android { android {
ndkVersion rootProject.ext.ndkVersion ndkVersion rootProject.ext.ndkVersion
@@ -94,8 +116,6 @@ android {
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1 versionCode 1
versionName "1.0.0" versionName "1.0.0"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
} }
signingConfigs { signingConfigs {
debug { debug {
@@ -113,23 +133,17 @@ android {
// Caution! In production, you need to generate your own keystore file. // Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android. // see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug signingConfig signingConfigs.debug
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false' shrinkResources (findProperty('android.enableShrinkResourcesInReleaseBuilds')?.toBoolean() ?: false)
shrinkResources enableShrinkResources.toBoolean() minifyEnabled enableProguardInReleaseBuilds
minifyEnabled enableMinifyInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true' crunchPngs (findProperty('android.enablePngCrunchInReleaseBuilds')?.toBoolean() ?: true)
crunchPngs enablePngCrunchInRelease.toBoolean()
} }
} }
packagingOptions { packagingOptions {
jniLibs { jniLibs {
def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false' useLegacyPackaging (findProperty('expo.useLegacyPackaging')?.toBoolean() ?: false)
useLegacyPackaging enableLegacyPackaging.toBoolean()
} }
} }
androidResources {
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
}
} }
// Apply static values from `gradle.properties` to the `android.packagingOptions` // Apply static values from `gradle.properties` to the `android.packagingOptions`
@@ -162,15 +176,15 @@ dependencies {
if (isGifEnabled) { if (isGifEnabled) {
// For animated gif support // For animated gif support
implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}") implementation("com.facebook.fresco:animated-gif:${reactAndroidLibs.versions.fresco.get()}")
} }
if (isWebpEnabled) { if (isWebpEnabled) {
// For webp support // For webp support
implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}") implementation("com.facebook.fresco:webpsupport:${reactAndroidLibs.versions.fresco.get()}")
if (isWebpAnimatedEnabled) { if (isWebpAnimatedEnabled) {
// Animated webp support // Animated webp support
implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}") implementation("com.facebook.fresco:animated-webp:${reactAndroidLibs.versions.fresco.get()}")
} }
} }
@@ -180,3 +194,8 @@ dependencies {
implementation jscFlavor implementation jscFlavor
} }
} }
if (rnVersion < versionToNumber(0, 75, 0)) {
apply from: new File(["node", "--print", "require.resolve('@react-native-community/cli-platform-android/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim(), "../native_modules.gradle");
applyNativeModulesAppBuildGradle(project)
}
@@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
</manifest>
+11 -5
View File
@@ -3,11 +3,11 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.CAMERA"/> <uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:replace="android:maxSdkVersion"/> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/> <uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.VIBRATE"/> <uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:replace="android:maxSdkVersion"/> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.VIEW"/> <action android:name="android.intent.action.VIEW"/>
@@ -15,16 +15,22 @@
<data android:scheme="https"/> <data android:scheme="https"/>
</intent> </intent>
</queries> </queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules"> <application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme">
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/> <meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.ENABLE_BSDIFF_PATCH_SUPPORT" android:value="true"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/> <meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/> <meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode|smallestScreenSize" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="unspecified"> <activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="unspecified">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="group.mai.avante"/>
</intent-filter>
</activity> </activity>
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" android:exported="false"/>
</application> </application>
</manifest> </manifest>
@@ -5,36 +5,46 @@ import android.content.res.Configuration
import com.facebook.react.PackageList import com.facebook.react.PackageList
import com.facebook.react.ReactApplication import com.facebook.react.ReactApplication
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage import com.facebook.react.ReactPackage
import com.facebook.react.ReactHost import com.facebook.react.ReactHost
import com.facebook.react.common.ReleaseLevel import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.soloader.SoLoader
import expo.modules.ApplicationLifecycleDispatcher import expo.modules.ApplicationLifecycleDispatcher
import expo.modules.ExpoReactHostFactory import expo.modules.ReactNativeHostWrapper
class MainApplication : Application(), ReactApplication { class MainApplication : Application(), ReactApplication {
override val reactHost: ReactHost by lazy { override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
ExpoReactHostFactory.getDefaultReactHost( this,
context = applicationContext, object : DefaultReactNativeHost(this) {
packageList = override fun getPackages(): List<ReactPackage> {
PackageList(this).packages.apply { // Packages that cannot be autolinked yet can be added manually here, for example:
// Packages that cannot be autolinked yet can be added manually here, for example: // packages.add(new MyReactNativePackage());
// add(MyReactNativePackage()) return PackageList(this).packages
} }
)
} override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
}
)
override val reactHost: ReactHost
get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost)
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
DefaultNewArchitectureEntryPoint.releaseLevel = try { SoLoader.init(this, false)
ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase()) if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
} catch (e: IllegalArgumentException) { // If you opted-in for the New Architecture, we load the native entry point for this app.
ReleaseLevel.STABLE load()
} }
loadReactNative(this)
ApplicationLifecycleDispatcher.onApplicationCreate(this) ApplicationLifecycleDispatcher.onApplicationCreate(this)
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

@@ -1,6 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splashscreen_background"/> <item android:drawable="@color/splashscreen_background"/>
<item>
<bitmap android:gravity="center" android:src="@drawable/splashscreen_logo"/>
</item>
</layer-list> </layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

+2 -1
View File
@@ -1,5 +1,6 @@
<resources> <resources>
<color name="splashscreen_background">#FFFFFF</color>
<color name="iconBackground">#E6F4FE</color> <color name="iconBackground">#E6F4FE</color>
<color name="colorPrimary">#023c69</color> <color name="colorPrimary">#023c69</color>
<color name="colorPrimaryDark">#ffffff</color>
<color name="splashscreen_background">#ffffff</color>
</resources> </resources>
+10 -4
View File
@@ -1,11 +1,17 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar"> <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:textColor">@android:color/black</item>
<item name="android:editTextStyle">@style/ResetEditText</item>
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item> <item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
<item name="colorPrimary">@color/colorPrimary</item> <item name="colorPrimary">@color/colorPrimary</item>
<item name="android:statusBarColor">@android:color/transparent</item> <item name="android:statusBarColor">#ffffff</item>
<item name="android:navigationBarColor">@android:color/transparent</item> </style>
<style name="ResetEditText" parent="@android:style/Widget.EditText">
<item name="android:padding">0dp</item>
<item name="android:textColorHint">#c8c8c8</item>
<item name="android:textColor">@android:color/black</item>
</style> </style>
<style name="Theme.App.SplashScreen" parent="AppTheme"> <style name="Theme.App.SplashScreen" parent="AppTheme">
<item name="android:windowBackground">@drawable/splashscreen_logo</item> <item name="android:windowBackground">@drawable/splashscreen</item>
</style> </style>
</resources> </resources>
+38 -17
View File
@@ -1,24 +1,45 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
repositories { ext {
google() buildToolsVersion = findProperty('android.buildToolsVersion') ?: '34.0.0'
mavenCentral() minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '23')
} compileSdkVersion = Integer.parseInt(findProperty('android.compileSdkVersion') ?: '34')
dependencies { targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '34')
classpath('com.android.tools.build:gradle') kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.23'
classpath('com.facebook.react:react-native-gradle-plugin')
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin') ndkVersion = "26.1.10909125"
} }
repositories {
google()
mavenCentral()
}
dependencies {
classpath('com.android.tools.build:gradle')
classpath('com.facebook.react:react-native-gradle-plugin')
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
}
} }
apply plugin: "com.facebook.react.rootproject"
allprojects { allprojects {
repositories { repositories {
google() maven {
mavenCentral() // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
maven { url 'https://www.jitpack.io' } url(new File(['node', '--print', "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim(), '../android'))
} }
} maven {
// Android JSC is installed from npm
url(new File(['node', '--print', "require.resolve('jsc-android/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim(), '../dist'))
}
apply plugin: "expo-root-project" google()
apply plugin: "com.facebook.react.rootproject" mavenCentral()
maven { url 'https://www.jitpack.io' }
}
}
// @generated begin expo-camera-import - expo prebuild (DO NOT MODIFY) sync-f244f4f3d8bf7229102e8f992b525b8602c74770
def expoCameraMavenPath = new File(["node", "--print", "require.resolve('expo-camera/package.json')"].execute(null, rootDir).text.trim(), "../android/maven")
allprojects { repositories { maven { url(expoCameraMavenPath) } } }
// @generated end expo-camera-import
+7 -8
View File
@@ -15,13 +15,16 @@ org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
# When configured, Gradle will run in incubating parallel mode. # When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit # This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
org.gradle.parallel=true # org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the # AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK # Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn # https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
# Enable AAPT2 PNG crunching # Enable AAPT2 PNG crunching
android.enablePngCrunchInReleaseBuilds=true android.enablePngCrunchInReleaseBuilds=true
@@ -35,17 +38,12 @@ reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# your application. You should enable this flag either if you want # your application. You should enable this flag either if you want
# to write custom TurboModules/Fabric components OR use libraries that # to write custom TurboModules/Fabric components OR use libraries that
# are providing them. # are providing them.
newArchEnabled=true newArchEnabled=false
# Use this property to enable or disable the Hermes JS engine. # Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead. # If set to false, you will be using JSC instead.
hermesEnabled=true hermesEnabled=true
# Use this property to enable edge-to-edge display support.
# This allows your app to draw behind system bars for an immersive UI.
# Note: Only works with ReactActivity and should not be used with custom Activity.
edgeToEdgeEnabled=true
# Enable GIF support in React Native images (~200 B increase) # Enable GIF support in React Native images (~200 B increase)
expo.gif.enabled=true expo.gif.enabled=true
# Enable webp support in React Native images (~85 KB increase) # Enable webp support in React Native images (~85 KB increase)
@@ -60,4 +58,5 @@ EX_DEV_CLIENT_NETWORK_INSPECTOR=true
# Use legacy packaging to compress native libraries in the resulting APK. # Use legacy packaging to compress native libraries in the resulting APK.
expo.useLegacyPackaging=false expo.useLegacyPackaging=false
expo.inlineModules.watchedDirectories=[] android.extraMavenRepos=[]
org.gradle.java.home=C\:/Android/gradle/jdks/eclipse_adoptium-17-amd64-windows.2
@@ -0,0 +1,3 @@
# This file is generated by the build process.
# Use it to specify the JVM that should be used by the Gradle daemon.
gradle.daemon.jvm.executable=C\:\\\\Android\\\\gradle\\\\jdks\\\\eclipse_adoptium-17-amd64-windows.2\\\\bin\\\\java.exe
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-all.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
+8 -7
View File
@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
# #
# Copyright © 2015 the original authors. # Copyright © 2015-2021 the original authors.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -15,8 +15,6 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
# #
# SPDX-License-Identifier: Apache-2.0
#
############################################################################## ##############################################################################
# #
@@ -57,7 +55,7 @@
# Darwin, MinGW, and NonStop. # Darwin, MinGW, and NonStop.
# #
# (3) This script is generated from the Groovy template # (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project. # within the Gradle project.
# #
# You can find Gradle at https://github.com/gradle/gradle/. # You can find Gradle at https://github.com/gradle/gradle/.
@@ -86,7 +84,7 @@ done
# shellcheck disable=SC2034 # shellcheck disable=SC2034
APP_BASE_NAME=${0##*/} APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value. # Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum MAX_FD=maximum
@@ -114,6 +112,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;; NONSTOP* ) nonstop=true ;;
esac esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM. # Determine the Java command to use to start the JVM.
@@ -171,6 +170,7 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java # For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" ) JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -203,14 +203,15 @@ fi
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command: # Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped. # and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line. # treated as '${Hostname}' itself on the command line.
set -- \ set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \ "-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ -classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@" "$@"
# Stop when "xargs" is not available. # Stop when "xargs" is not available.
+2 -8
View File
@@ -1,8 +1,3 @@
@REM Copyright (c) Meta Platforms, Inc. and affiliates.
@REM
@REM This source code is licensed under the MIT license found in the
@REM LICENSE file in the root directory of this source tree.
@rem @rem
@rem Copyright 2015 the original author or authors. @rem Copyright 2015 the original author or authors.
@rem @rem
@@ -18,8 +13,6 @@
@rem See the License for the specific language governing permissions and @rem See the License for the specific language governing permissions and
@rem limitations under the License. @rem limitations under the License.
@rem @rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off @if "%DEBUG%"=="" @echo off
@rem ########################################################################## @rem ##########################################################################
@@ -75,10 +68,11 @@ goto fail
:execute :execute
@rem Setup the command line @rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle @rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end :end
@rem End local scope for the variables with windows NT shell @rem End local scope for the variables with windows NT shell
@@ -0,0 +1,19 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
kotlin("jvm") version "1.9.24"
id("java-gradle-plugin")
}
repositories {
mavenCentral()
}
gradlePlugin {
plugins {
create("reactSettingsPlugin") {
id = "com.facebook.react.settings"
implementationClass = "expo.plugins.ReactSettingsPlugin"
}
}
}
@@ -0,0 +1,10 @@
package expo.plugins
import org.gradle.api.Plugin
import org.gradle.api.initialization.Settings
class ReactSettingsPlugin : Plugin<Settings> {
override fun apply(settings: Settings) {
// Do nothing, just register the plugin.
}
}
+53 -26
View File
@@ -1,39 +1,66 @@
pluginManagement { pluginManagement {
def reactNativeGradlePlugin = new File( def version = providers.exec {
providers.exec { commandLine("node", "-e", "console.log(require('react-native/package.json').version);")
workingDir(rootDir) }.standardOutput.asText.get().trim()
commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })") def (_, reactNativeMinor, reactNativePatch) = version.split("-")[0].tokenize('.').collect { it.toInteger() }
}.standardOutput.asText.get().trim()
).getParentFile().absolutePath
includeBuild(reactNativeGradlePlugin)
def expoPluginsPath = new File( includeBuild(new File(["node", "--print", "require.resolve('@react-native/gradle-plugin/package.json')"].execute(null, rootDir).text.trim()).getParentFile().toString())
providers.exec { if(reactNativeMinor == 74 && reactNativePatch <= 3){
workingDir(rootDir) includeBuild("react-settings-plugin")
commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })") }
}.standardOutput.asText.get().trim(),
"../android/expo-gradle-plugin"
).absolutePath
includeBuild(expoPluginsPath)
} }
plugins { plugins { id("com.facebook.react.settings") }
id("com.facebook.react.settings")
id("expo-autolinking-settings") def getRNMinorVersion() {
def version = providers.exec {
commandLine("node", "-e", "console.log(require('react-native/package.json').version);")
}.standardOutput.asText.get().trim()
def coreVersion = version.split("-")[0]
def (major, minor, patch) = coreVersion.tokenize('.').collect { it.toInteger() }
return minor
} }
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex -> if (getRNMinorVersion() >= 75) {
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') { extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
ex.autolinkLibrariesFromCommand() if (System.getenv('EXPO_UNSTABLE_CORE_AUTOLINKING') == '1') {
} else { println('\u001B[32mUsing expo-modules-autolinking as core autolinking source\u001B[0m')
ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand) def command = [
'node',
'--no-warnings',
'--eval',
'require(require.resolve(\'expo-modules-autolinking\', { paths: [require.resolve(\'expo/package.json\')] }))(process.argv.slice(1))',
'react-native-config',
'--json',
'--platform',
'android'
].toList()
ex.autolinkLibrariesFromCommand(command)
} else {
ex.autolinkLibrariesFromCommand()
}
} }
} }
expoAutolinking.useExpoModules()
rootProject.name = 'Avante' rootProject.name = 'Avante'
expoAutolinking.useExpoVersionCatalog() dependencyResolutionManagement {
versionCatalogs {
reactAndroidLibs {
from(files(new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim(), "../gradle/libs.versions.toml")))
}
}
}
apply from: new File(["node", "--print", "require.resolve('expo/package.json')"].execute(null, rootDir).text.trim(), "../scripts/autolinking.gradle");
useExpoModules()
if (getRNMinorVersion() < 75) {
apply from: new File(["node", "--print", "require.resolve('@react-native-community/cli-platform-android/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim(), "../native_modules.gradle");
applyNativeModulesSettingsGradle(settings)
}
include ':app' include ':app'
includeBuild(expoAutolinking.reactNativeGradlePlugin) includeBuild(new File(["node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile())
-1
View File
@@ -32,7 +32,6 @@
"favicon": "./assets/favicon.png" "favicon": "./assets/favicon.png"
}, },
"plugins": [ "plugins": [
"expo-sqlite",
"expo-secure-store", "expo-secure-store",
"expo-build-properties", "expo-build-properties",
[ [
+9922 -3822
View File
File diff suppressed because it is too large Load Diff
+25 -21
View File
@@ -3,29 +3,33 @@
"version": "1.0.0", "version": "1.0.0",
"main": "index.ts", "main": "index.ts",
"dependencies": { "dependencies": {
"@react-native-community/netinfo": "12.0.1", "@react-native-community/netinfo": "11.3.1",
"@react-navigation/native": "^7.3.3", "@react-navigation/native": "^6.1.17",
"@react-navigation/native-stack": "^7.17.5", "@react-navigation/native-stack": "^6.9.26",
"expo": "~56.0.12", "expo": "~51.0.28",
"expo-build-properties": "~56.0.19", "expo-build-properties": "~0.12.5",
"expo-crypto": "~56.0.4", "expo-camera": "~15.0.16",
"expo-file-system": "~56.0.8", "expo-crypto": "~13.0.2",
"expo-image-picker": "~56.0.18", "expo-file-system": "~17.0.1",
"expo-location": "~56.0.18", "expo-image-picker": "~15.1.0",
"expo-secure-store": "~56.0.4", "expo-location": "~17.0.1",
"expo-sqlite": "~56.0.5", "expo-navigation-bar": "~3.0.7",
"expo-status-bar": "~56.0.4", "expo-screen-orientation": "~7.0.1",
"react": "19.2.3", "expo-secure-store": "~13.0.2",
"react-native": "0.85.3", "expo-sqlite": "~14.0.6",
"react-native-maps": "1.27.2", "expo-status-bar": "~1.12.1",
"react-native-view-shot": "^4.0.0", "react": "18.2.0",
"react-native-safe-area-context": "~5.7.0", "react-native": "0.74.5",
"react-native-screens": "4.25.2" "react-native-maps": "1.14.0",
"react-native-safe-area-context": "4.10.5",
"react-native-screens": "3.31.1",
"react-native-view-shot": "3.8.0",
"react-native-webview": "13.8.6"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~19.2.2", "@types/react": "~18.2.79",
"eas-cli": "^20.2.0", "eas-cli": "^12.6.2",
"typescript": "~6.0.3" "typescript": "~5.3.3"
}, },
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
+1
View File
@@ -153,6 +153,7 @@ export interface Template {
export type MediaParentEntity = export type MediaParentEntity =
| 'feature' | 'feature'
| 'inspection'
| 'issue' | 'issue'
| 'issue_task' | 'issue_task'
| 'issue_comment' | 'issue_comment'
+11
View File
@@ -265,3 +265,14 @@ export async function markMediaError(uuid: string, error: string): Promise<void>
uuid, uuid,
); );
} }
export async function deleteMediaOutbox(uuid: string): Promise<void> {
const db = await getDb();
await db.runAsync('DELETE FROM media_outbox WHERE uuid = ?', uuid);
}
/** Actualiza la ruta local de un fichero (usado tras añadir el sello en 2º plano). */
export async function updateMediaLocalUri(uuid: string, localUri: string): Promise<void> {
const db = await getDb();
await db.runAsync('UPDATE media_outbox SET local_uri = ? WHERE uuid = ?', localUri, uuid);
}
+66 -46
View File
@@ -12,13 +12,21 @@ import { InspectionFormScreen } from '../screens/InspectionFormScreen';
import { IssueCreateScreen } from '../screens/IssueCreateScreen'; import { IssueCreateScreen } from '../screens/IssueCreateScreen';
import { OutboxScreen } from '../screens/OutboxScreen'; import { OutboxScreen } from '../screens/OutboxScreen';
import { PhotoSettingsScreen } from '../screens/PhotoSettingsScreen'; import { PhotoSettingsScreen } from '../screens/PhotoSettingsScreen';
import { SettingsScreen } from '../screens/SettingsScreen';
import { CameraScreen } from '../screens/CameraScreen';
import { RootStackParamList } from './types'; import { RootStackParamList } from './types';
import { useAutoSync } from '../sync/useAutoSync';
import { runPushOnly } from '../sync/engine';
const Stack = createNativeStackNavigator<RootStackParamList>(); const Stack = createNativeStackNavigator<RootStackParamList>();
export function RootNavigator() { export function RootNavigator() {
const { ready, token } = useSession(); const { ready, token } = useSession();
// Auto-sync global: vacía el outbox siempre que haya red y estemos logueados.
// Esto asegura que el trabajo offline se envíe aunque no se entre en un proyecto.
useAutoSync(!!token, runPushOnly);
if (!ready) { if (!ready) {
return ( return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}> <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
@@ -27,54 +35,66 @@ export function RootNavigator() {
); );
} }
if (!token) {
return <LoginScreen />;
}
return ( return (
<NavigationContainer> <NavigationContainer>
<Stack.Navigator> {!token ? (
<Stack.Screen <Stack.Navigator screenOptions={{ headerShown: false }}>
name="Projects" <Stack.Screen name="Login" component={LoginScreen} />
component={ProjectsScreen} </Stack.Navigator>
options={{ title: 'Proyectos' }} ) : (
/> <Stack.Navigator>
<Stack.Screen <Stack.Screen
name="ProjectDetail" name="Projects"
component={ProjectDetailScreen} component={ProjectsScreen}
options={({ route }) => ({ title: route.params.name })} options={{ title: 'Proyectos' }}
/> />
<Stack.Screen <Stack.Screen
name="IssueDetail" name="ProjectDetail"
component={IssueDetailScreen} component={ProjectDetailScreen}
options={({ route }) => ({ title: route.params.title })} options={({ route }) => ({ title: route.params.name })}
/> />
<Stack.Screen <Stack.Screen
name="FeatureDetail" name="IssueDetail"
component={FeatureDetailScreen} component={IssueDetailScreen}
options={({ route }) => ({ title: route.params.name })} options={({ route }) => ({ title: route.params.title })}
/> />
<Stack.Screen <Stack.Screen
name="InspectionForm" name="FeatureDetail"
component={InspectionFormScreen} component={FeatureDetailScreen}
options={{ title: 'Nueva inspección', presentation: 'modal' }} options={({ route }) => ({ title: route.params.name })}
/> />
<Stack.Screen <Stack.Screen
name="IssueCreate" name="InspectionForm"
component={IssueCreateScreen} component={InspectionFormScreen}
options={{ title: 'Nueva incidencia', presentation: 'modal' }} options={{ title: 'Nueva inspección', presentation: 'modal' }}
/> />
<Stack.Screen <Stack.Screen
name="Outbox" name="IssueCreate"
component={OutboxScreen} component={IssueCreateScreen}
options={{ title: 'Cola de sincronización' }} options={{ title: 'Nueva incidencia', presentation: 'modal' }}
/> />
<Stack.Screen <Stack.Screen
name="PhotoSettings" name="Outbox"
component={PhotoSettingsScreen} component={OutboxScreen}
options={{ title: 'Configuración de fotos', presentation: 'modal' }} options={{ title: 'Cola de sincronización' }}
/> />
</Stack.Navigator> <Stack.Screen
name="PhotoSettings"
component={PhotoSettingsScreen}
options={{ title: 'Configuración de fotos', presentation: 'modal' }}
/>
<Stack.Screen
name="Settings"
component={SettingsScreen}
options={{ title: 'Configuración' }}
/>
<Stack.Screen
name="Camera"
component={CameraScreen}
options={{ headerShown: false, orientation: 'portrait' }}
/>
</Stack.Navigator>
)}
</NavigationContainer> </NavigationContainer>
); );
} }
+3
View File
@@ -1,4 +1,5 @@
export type RootStackParamList = { export type RootStackParamList = {
Login: undefined;
Projects: undefined; Projects: undefined;
ProjectDetail: { projectId: number; name: string }; ProjectDetail: { projectId: number; name: string };
IssueDetail: { issueId: number; title: string }; IssueDetail: { issueId: number; title: string };
@@ -7,4 +8,6 @@ export type RootStackParamList = {
IssueCreate: { projectId: number; featureId?: number }; IssueCreate: { projectId: number; featureId?: number };
Outbox: undefined; Outbox: undefined;
PhotoSettings: undefined; PhotoSettings: undefined;
Settings: undefined;
Camera: { onCapture: (uri: string, width: number, height: number) => void };
}; };
+40 -38
View File
@@ -1,50 +1,55 @@
/** /**
* Pie de página superpuesto sobre la foto antes de capturarla con ViewShot. * Pie de página superpuesto sobre la foto.
* Se posiciona en la parte inferior de su contenedor con position:absolute. * Se posiciona en una de las cuatro esquinas según `config.overlayPosition`.
* Todos los tamaños son proporcionales al ancho de la imagen para que el pie * El diseño es compacto (recuadro con fondo semitransparente).
* sea legible independientemente de la resolución del sensor.
*/ */
import React from 'react'; import React from 'react';
import { Image, StyleSheet, Text, View } from 'react-native'; import { Image, StyleSheet, Text, View, ViewStyle } from 'react-native';
import { FooterConfig, resolveFieldValue, StampMeta } from './footerConfig'; import { FooterConfig, resolveFieldValue, StampMeta } from './footerConfig';
interface Props { interface Props {
config: FooterConfig; config: FooterConfig;
meta: StampMeta; meta: StampMeta;
imageWidth: number; imageWidth: number;
rotation?: number; // Rotación extra para la preview de cámara
} }
export function FooterOverlay({ config, meta, imageWidth }: Props) { export function FooterOverlay({ config, meta, imageWidth, rotation = 0 }: Props) {
if (!config.enabled) return null; if (!config.enabled) return null;
const enabled = config.fields.filter((f) => f.enabled); const enabled = config.fields.filter((f) => f.enabled);
if (enabled.length === 0 && !config.logoUri) return null; if (enabled.length === 0 && !config.logoUri) return null;
const fontSize = Math.max(12, Math.round(imageWidth * 0.016)); const fontSize = Math.max(10, Math.round(imageWidth * 0.022));
const logoDim = Math.max(50, Math.round(imageWidth * 0.09)); const logoDim = Math.max(40, Math.round(imageWidth * 0.10));
const pad = Math.max(8, Math.round(imageWidth * 0.012)); const pad = Math.max(8, Math.round(imageWidth * 0.02));
const lineH = Math.round(fontSize * 1.4); const lineH = Math.round(fontSize * 1.3);
// Determinar posición física
const posStyle: ViewStyle = {};
if (config.overlayPosition.includes('top')) posStyle.top = 20;
else posStyle.bottom = 20;
if (config.overlayPosition.includes('left')) posStyle.left = 20;
else posStyle.right = 20;
// Aplicar rotación si se indica (para la cámara)
if (rotation !== 0) {
posStyle.transform = [{ rotate: `${rotation}deg` }];
}
return ( return (
<View <View style={[styles.container, posStyle, { padding: pad }]}>
style={[
styles.container,
{ paddingHorizontal: pad, paddingVertical: Math.round(pad * 0.65) },
]}
>
{/* Logo */} {/* Logo */}
{config.logoUri ? ( {config.logoUri ? (
<> <View
<View style={[
style={[ styles.logoWrap,
styles.logoWrap, { width: logoDim, height: logoDim, borderRadius: Math.round(logoDim * 0.1), marginBottom: 4 },
{ width: logoDim, height: logoDim, borderRadius: Math.round(logoDim * 0.08) }, ]}
]} >
> <Image source={{ uri: config.logoUri }} style={styles.logoImg} resizeMode="contain" />
<Image source={{ uri: config.logoUri }} style={styles.logoImg} resizeMode="contain" /> </View>
</View>
<View style={[styles.divider, { marginHorizontal: pad, height: logoDim }]} />
</>
) : null} ) : null}
{/* Campos */} {/* Campos */}
@@ -60,7 +65,7 @@ export function FooterOverlay({ config, meta, imageWidth }: Props) {
{ fontSize, lineHeight: lineH }, { fontSize, lineHeight: lineH },
i === 0 ? styles.bold : null, i === 0 ? styles.bold : null,
]} ]}
numberOfLines={1} numberOfLines={2}
> >
{f.key === 'custom' ? `${f.label}: ${val}` : val} {f.key === 'custom' ? `${f.label}: ${val}` : val}
</Text> </Text>
@@ -74,23 +79,20 @@ export function FooterOverlay({ config, meta, imageWidth }: Props) {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
position: 'absolute', position: 'absolute',
bottom: 0, backgroundColor: 'rgba(0,0,0,0.65)',
left: 0, borderRadius: 8,
right: 0, maxWidth: '70%', // Un poco más ancho para evitar cortes agresivos
backgroundColor: 'rgba(0,0,0,0.75)', alignItems: 'flex-start',
flexDirection: 'row', zIndex: 1000,
alignItems: 'center',
}, },
logoWrap: { logoWrap: {
backgroundColor: '#fff', backgroundColor: '#fff',
overflow: 'hidden', overflow: 'hidden',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
flexShrink: 0,
}, },
logoImg: { width: '100%', height: '100%' }, logoImg: { width: '85%', height: '85%' },
divider: { width: 1, backgroundColor: 'rgba(255,255,255,0.35)', flexShrink: 0 }, textBlock: { width: '100%' },
textBlock: { flex: 1 },
line: { color: '#fff' }, line: { color: '#fff' },
bold: { fontWeight: '700' }, bold: { fontWeight: '700' },
}); });
+25 -4
View File
@@ -1,8 +1,11 @@
import * as FileSystem from 'expo-file-system/legacy'; import * as FileSystem from 'expo-file-system';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { getMeta, setMeta } from '../db/repositories'; import { getMeta, setMeta } from '../db/repositories';
export type FieldKey = 'project_name' | 'date' | 'coordinates' | 'custom'; export type FieldKey = 'project_name' | 'date' | 'coordinates' | 'custom';
export type PhotoResolution = 'low' | 'medium' | 'high';
export type PhotoAspectRatio = '1:1' | '4:3' | '16:9' | 'full';
export type OverlayPosition = 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right';
export interface FooterField { export interface FooterField {
id: string; id: string;
@@ -17,6 +20,10 @@ export interface FooterConfig {
enabled: boolean; enabled: boolean;
logoUri: string | null; logoUri: string | null;
fields: FooterField[]; fields: FooterField[];
resolution: PhotoResolution;
aspectRatio: PhotoAspectRatio;
quality: number; // 0.1 to 1.0
overlayPosition: OverlayPosition;
} }
export interface StampMeta { export interface StampMeta {
@@ -37,16 +44,30 @@ function defaultConfig(): FooterConfig {
{ id: 'date', key: 'date', label: 'Fecha', enabled: true }, { id: 'date', key: 'date', label: 'Fecha', enabled: true },
{ id: 'coordinates', key: 'coordinates', label: 'Coordenadas GPS', enabled: true }, { id: 'coordinates', key: 'coordinates', label: 'Coordenadas GPS', enabled: true },
], ],
resolution: 'medium',
aspectRatio: '4:3',
quality: 0.85,
overlayPosition: 'bottom-left',
}; };
} }
/** Mapa de resoluciones a píxeles (ancho). El alto se calcula según el aspect ratio. */
export const RESOLUTION_WIDTHS: Record<PhotoResolution, number> = {
low: 1280, // 720p approx
medium: 1920, // 1080p
high: 3840, // 4K approx
};
export async function loadFooterConfig(): Promise<FooterConfig> { export async function loadFooterConfig(): Promise<FooterConfig> {
const raw = await getMeta(META_KEY); const raw = await getMeta(META_KEY);
if (!raw) return defaultConfig(); const defaults = defaultConfig();
if (!raw) return defaults;
try { try {
return JSON.parse(raw) as FooterConfig; const parsed = JSON.parse(raw);
// Mezclar con defaults para asegurar que campos nuevos (resolution, position) existan
return { ...defaults, ...parsed };
} catch { } catch {
return defaultConfig(); return defaults;
} }
} }
+250
View File
@@ -0,0 +1,250 @@
import { CameraView, useCameraPermissions } from 'expo-camera';
import * as Location from 'expo-location';
import * as ScreenOrientation from 'expo-screen-orientation';
import * as NavigationBar from 'expo-navigation-bar';
import { useFocusEffect } from '@react-navigation/native';
import React, { useCallback, useRef, useState } from 'react';
import {
StyleSheet,
Text,
TouchableOpacity,
View,
ActivityIndicator,
useWindowDimensions,
Platform,
StatusBar,
} from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { RootStackParamList } from '../navigation/types';
import { FooterConfig, loadFooterConfig, StampMeta } from '../photo/footerConfig';
import { getActiveProjectId, getProject } from '../db/repositories';
import { FooterOverlay } from '../photo/FooterOverlay';
type Props = NativeStackScreenProps<RootStackParamList, 'Camera'>;
export function CameraScreen({ route, navigation }: Props) {
const { onCapture } = route.params;
const [permission, requestPermission] = useCameraPermissions();
const cameraRef = useRef<CameraView>(null);
const { width: winW, height: winH } = useWindowDimensions();
const [config, setConfig] = useState<FooterConfig | null>(null);
const [meta, setMeta] = useState<StampMeta | null>(null);
const [taking, setTaking] = useState(false);
const [rotation, setRotation] = useState(0);
// Calcula dimensiones del visor según relación de aspecto
const getPreviewDims = () => {
if (!config) return { w: winW, h: winH, ratio: '16:9' as const };
const ratio = config.aspectRatio;
if (ratio === 'full') return { w: winW, h: winH, ratio: '16:9' as const }; // Usar 16:9 para full e intentar llenar
if (ratio === '1:1') return { w: winW, h: winW, ratio: '4:3' as const };
if (ratio === '4:3') return { w: winW, h: (winW * 4) / 3, ratio: '4:3' as const };
if (ratio === '16:9') return { w: winW, h: (winW * 16) / 9, ratio: '16:9' as const };
return { w: winW, h: winH, ratio: '16:9' as const };
};
const preview = getPreviewDims();
// Carga de datos cada vez que la pantalla gana el foco
useFocusEffect(
useCallback(() => {
let isMounted = true;
// Ocultar barras del sistema (Modo Inmersivo)
if (Platform.OS === 'android') {
void NavigationBar.setVisibilityAsync('hidden');
void NavigationBar.setBehaviorAsync('inset-touch');
}
StatusBar.setHidden(true);
const load = async () => {
const [cfg, projectId] = await Promise.all([
loadFooterConfig(),
getActiveProjectId(),
]);
if (!isMounted) return;
setConfig(cfg);
let projectName: string | null = null;
if (projectId != null) {
const proj = await getProject(projectId);
projectName = proj?.name ?? null;
}
const { granted } = await Location.requestForegroundPermissionsAsync();
let coordsStr: string | null = null;
if (granted) {
const loc = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced });
const lat = loc.coords.latitude;
const lng = loc.coords.longitude;
const la = lat >= 0 ? 'N' : 'S';
const lo = lng >= 0 ? 'E' : 'O';
coordsStr = `${Math.abs(lat).toFixed(5)}°${la}, ${Math.abs(lng).toFixed(5)}°${lo}`;
}
if (isMounted) {
setMeta({
projectName,
date: new Date().toLocaleString('es-ES', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
}),
coordinates: coordsStr,
});
}
};
const syncOrientation = async () => {
const info = await ScreenOrientation.getOrientationAsync();
handleOrientation(info);
};
const handleOrientation = (o: ScreenOrientation.Orientation) => {
if (o === ScreenOrientation.Orientation.LANDSCAPE_LEFT) setRotation(90);
else if (o === ScreenOrientation.Orientation.LANDSCAPE_RIGHT) setRotation(-90);
else if (o === ScreenOrientation.Orientation.PORTRAIT_UPSIDE_DOWN) setRotation(180);
else setRotation(0);
};
void load();
void syncOrientation();
const sub = ScreenOrientation.addOrientationChangeListener((evt) => {
handleOrientation(evt.orientationInfo.orientation);
});
return () => {
isMounted = false;
ScreenOrientation.removeOrientationChangeListener(sub);
// Restaurar barras del sistema al salir
if (Platform.OS === 'android') {
void NavigationBar.setVisibilityAsync('visible');
}
StatusBar.setHidden(false);
};
}, [])
);
if (!permission) {
return <View style={styles.center}><ActivityIndicator size="large" /></View>;
}
if (!permission.granted) {
return (
<View style={styles.center}>
<Text style={styles.message}>Necesitamos permiso para usar la cámara</Text>
<TouchableOpacity style={styles.btn} onPress={requestPermission}>
<Text style={styles.btnText}>Conceder permiso</Text>
</TouchableOpacity>
</View>
);
}
const capture = async () => {
if (!cameraRef.current || taking) return;
setTaking(true);
try {
const photo = await cameraRef.current.takePictureAsync({
quality: 0.9, // Máxima calidad para el raw
base64: false,
exif: true,
});
if (photo) {
onCapture(photo.uri, photo.width, photo.height);
navigation.goBack();
}
} catch (e) {
console.error('Error taking picture:', e);
} finally {
setTaking(false);
}
};
return (
<View style={styles.container}>
<View style={[styles.cameraContainer, { height: preview.h, width: preview.w }]}>
<CameraView
ref={cameraRef}
style={StyleSheet.absoluteFill}
facing="back"
autofocus="on"
ratio={preview.ratio}
/>
</View>
<View style={styles.overlay}>
{/* Header con botón cerrar */}
<View style={styles.header}>
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.closeBtn}>
<Text style={styles.closeTxt}></Text>
</TouchableOpacity>
</View>
{/* Preview del sello: posicionado por FooterOverlay, no por este contenedor */}
{config && meta && (
<View style={styles.fullOverlay} pointerEvents="none">
<FooterOverlay
config={config}
meta={meta}
imageWidth={Math.min(winW, winH)}
rotation={rotation}
/>
</View>
)}
{/* Botón de disparo */}
<View style={styles.footerControls}>
<TouchableOpacity
style={[styles.captureBtn, taking && styles.disabled]}
onPress={() => void capture()}
disabled={taking}
>
<View style={styles.captureInner} />
</TouchableOpacity>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#000', justifyContent: 'center', alignItems: 'center' },
cameraContainer: { overflow: 'hidden' },
overlay: { ...StyleSheet.absoluteFillObject, justifyContent: 'space-between', zIndex: 10 },
fullOverlay: {
...StyleSheet.absoluteFillObject,
// IMPORTANTE: Eliminado justifyContent: center.
// Ahora FooterOverlay se anclará a las esquinas correctamente.
},
center: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#000' },
message: { color: '#fff', textAlign: 'center', marginBottom: 20 },
btn: { backgroundColor: '#1f6f43', padding: 12, borderRadius: 8 },
btnText: { color: '#fff', fontWeight: 'bold' },
header: { padding: 20, paddingTop: 50 },
closeBtn: { width: 44, height: 44, justifyContent: 'center', alignItems: 'center', backgroundColor: 'rgba(0,0,0,0.4)', borderRadius: 22 },
closeTxt: { color: '#fff', fontSize: 24 },
footerControls: {
paddingBottom: 40,
alignItems: 'center',
width: '100%',
},
captureBtn: {
width: 74,
height: 74,
borderRadius: 37,
borderWidth: 5,
borderColor: '#fff',
justifyContent: 'center',
alignItems: 'center',
},
captureInner: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: '#fff',
},
disabled: { opacity: 0.5 },
});
+39 -21
View File
@@ -12,11 +12,14 @@ import { Alert, ScrollView, StyleSheet, Switch, Text, TouchableOpacity, View } f
import { Template } from '../api/types'; import { Template } from '../api/types';
import { getTemplates } from '../db/repositories'; import { getTemplates } from '../db/repositories';
import { createInspection } from '../sync/mutations'; import { createInspection } from '../sync/mutations';
import { getDb } from '../db/database';
import { newUuid, nextTempId } from '../sync/uuid';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { RootStackParamList } from '../navigation/types'; import { RootStackParamList } from '../navigation/types';
import { MediaStrip } from '../ui/MediaStrip';
import { import {
Card, Card,
ChipSelect, ChipSelect,
COLORS,
Field, Field,
PrimaryButton, PrimaryButton,
SectionTitle, SectionTitle,
@@ -75,6 +78,7 @@ function groupFields(fields: NormField[]): { group: string; items: NormField[] }
const RESULTS = ['pass', 'fail', 'na'] as const; const RESULTS = ['pass', 'fail', 'na'] as const;
export function InspectionFormScreen({ route, navigation }: Props) { export function InspectionFormScreen({ route, navigation }: Props) {
const insets = useSafeAreaInsets();
const { featureId, featureName, templateId: suggestedId } = route.params; const { featureId, featureName, templateId: suggestedId } = route.params;
// Paso 1: elegir plantilla (las asignadas al proyecto llegan en el bundle). // Paso 1: elegir plantilla (las asignadas al proyecto llegan en el bundle).
@@ -87,6 +91,10 @@ export function InspectionFormScreen({ route, navigation }: Props) {
const [notes, setNotes] = useState(''); const [notes, setNotes] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
// We need the tempId and uuid for the inspection to associate photos with it before saving
const [inspectionTempId, setInspectionTempId] = useState<number | null>(null);
const [inspectionUuid, setInspectionUuid] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
void getTemplates().then((all) => { void getTemplates().then((all) => {
// La plantilla sugerida (la de la feature) primero. // La plantilla sugerida (la de la feature) primero.
@@ -102,12 +110,17 @@ export function InspectionFormScreen({ route, navigation }: Props) {
setTemplate(t); setTemplate(t);
setValues({}); setValues({});
setChosen(true); setChosen(true);
// Generate IDs early so photos can be associated
setInspectionTempId(nextTempId());
setInspectionUuid(newUuid());
}, []); }, []);
const backToPicker = useCallback(() => { const backToPicker = useCallback(() => {
setChosen(false); setChosen(false);
setTemplate(null); setTemplate(null);
setValues({}); setValues({});
setInspectionTempId(null);
setInspectionUuid(null);
}, []); }, []);
const fields: NormField[] = (template?.fields ?? []).map(normalizeField); const fields: NormField[] = (template?.fields ?? []).map(normalizeField);
@@ -133,6 +146,7 @@ export function InspectionFormScreen({ route, navigation }: Props) {
} }
setSaving(true); setSaving(true);
try { try {
// Create the inspection using the pre-generated IDs
await createInspection({ await createInspection({
feature_id: featureId, feature_id: featureId,
template_id: template?.id ?? undefined, template_id: template?.id ?? undefined,
@@ -140,13 +154,15 @@ export function InspectionFormScreen({ route, navigation }: Props) {
result, result,
notes: notes.trim() || undefined, notes: notes.trim() || undefined,
status: 'completed', status: 'completed',
uuid: inspectionUuid ?? undefined,
localId: inspectionTempId ?? undefined,
}); });
navigation.goBack(); navigation.goBack();
} finally { } finally {
setSaving(false); setSaving(false);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [featureId, values, result, notes, navigation, template]); }, [featureId, values, result, notes, navigation, template, inspectionUuid, inspectionTempId]);
const renderField = (f: NormField) => { const renderField = (f: NormField) => {
const label = f.required ? `${f.label} *` : f.label; const label = f.required ? `${f.label} *` : f.label;
@@ -189,7 +205,7 @@ export function InspectionFormScreen({ route, navigation }: Props) {
// ── Paso 1: selector de plantilla ── // ── Paso 1: selector de plantilla ──
if (!chosen) { if (!chosen) {
return ( return (
<ScrollView contentContainerStyle={styles.body}> <ScrollView contentContainerStyle={[styles.body, { paddingBottom: insets.bottom + 20 }]}>
<Text style={styles.subtitle}>{featureName}</Text> <Text style={styles.subtitle}>{featureName}</Text>
<Text style={styles.tplName}>Elige una plantilla</Text> <Text style={styles.tplName}>Elige una plantilla</Text>
@@ -220,22 +236,13 @@ export function InspectionFormScreen({ route, navigation }: Props) {
<Text style={styles.tplChevron}></Text> <Text style={styles.tplChevron}></Text>
</TouchableOpacity> </TouchableOpacity>
))} ))}
<View style={{ height: 8 }} />
<PrimaryButton
title="Inspección libre (sin plantilla)"
variant="ghost"
onPress={() => pickTemplate(null)}
/>
<View style={{ height: 8 }} />
<PrimaryButton title="Cancelar" variant="ghost" onPress={() => navigation.goBack()} />
</ScrollView> </ScrollView>
); );
} }
// ── Paso 2: formulario ── // ── Paso 2: formulario ──
return ( return (
<ScrollView contentContainerStyle={styles.body}> <ScrollView contentContainerStyle={[styles.body, { paddingBottom: insets.bottom + 20 }]}>
<Text style={styles.subtitle}>{featureName}</Text> <Text style={styles.subtitle}>{featureName}</Text>
<Text style={styles.tplName}>{template?.name ?? 'Inspección libre'}</Text> <Text style={styles.tplName}>{template?.name ?? 'Inspección libre'}</Text>
<TouchableOpacity onPress={backToPicker}> <TouchableOpacity onPress={backToPicker}>
@@ -261,6 +268,17 @@ export function InspectionFormScreen({ route, navigation }: Props) {
/> />
</Card> </Card>
{inspectionTempId && (
<View style={{ marginBottom: 12 }}>
<SectionTitle>Fotos de la inspección</SectionTitle>
<MediaStrip
parentEntity="inspection"
parentId={inspectionTempId}
canUpload={true}
/>
</View>
)}
<PrimaryButton title="Guardar inspección" onPress={() => void onSubmit()} loading={saving} /> <PrimaryButton title="Guardar inspección" onPress={() => void onSubmit()} loading={saving} />
<View style={{ height: 8 }} /> <View style={{ height: 8 }} />
<PrimaryButton title="Cancelar" variant="ghost" onPress={() => navigation.goBack()} /> <PrimaryButton title="Cancelar" variant="ghost" onPress={() => navigation.goBack()} />
@@ -270,7 +288,7 @@ export function InspectionFormScreen({ route, navigation }: Props) {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
body: { padding: 16 }, body: { padding: 16 },
subtitle: { color: COLORS.muted, fontSize: 13 }, subtitle: { color: '#666', fontSize: 13 }, // muted
tplName: { fontSize: 18, fontWeight: '700', marginBottom: 12 }, tplName: { fontSize: 18, fontWeight: '700', marginBottom: 12 },
switchRow: { switchRow: {
flexDirection: 'row', flexDirection: 'row',
@@ -279,21 +297,21 @@ const styles = StyleSheet.create({
paddingVertical: 10, paddingVertical: 10,
}, },
switchLabel: { fontSize: 15, flex: 1 }, switchLabel: { fontSize: 15, flex: 1 },
help: { fontSize: 12, color: COLORS.muted, marginTop: -6, marginBottom: 8 }, help: { fontSize: 12, color: '#666', marginTop: -6, marginBottom: 8 }, // muted
changeTpl: { color: COLORS.primary, fontSize: 13, fontWeight: '600', marginBottom: 12 }, changeTpl: { color: '#1f6f43', fontSize: 13, fontWeight: '600', marginBottom: 12 }, // primary
noTemplates: { color: COLORS.muted, fontSize: 14, marginVertical: 16, textAlign: 'center' }, noTemplates: { color: '#666', fontSize: 14, marginVertical: 16, textAlign: 'center' }, // muted
tplCard: { tplCard: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
borderWidth: 1, borderWidth: 1,
borderColor: COLORS.border, borderColor: '#ddd', // border
borderRadius: 10, borderRadius: 10,
padding: 14, padding: 14,
marginBottom: 8, marginBottom: 8,
backgroundColor: '#fff', backgroundColor: '#fff',
}, },
tplCardName: { fontSize: 15, fontWeight: '700' }, tplCardName: { fontSize: 15, fontWeight: '700' },
tplCardDesc: { fontSize: 13, color: COLORS.muted, marginTop: 2 }, tplCardDesc: { fontSize: 13, color: '#666', marginTop: 2 }, // muted
tplCardMeta: { fontSize: 11, color: COLORS.muted, marginTop: 4 }, tplCardMeta: { fontSize: 11, color: '#666', marginTop: 4 }, // muted
tplChevron: { fontSize: 24, color: COLORS.muted, marginLeft: 8 }, tplChevron: { fontSize: 24, color: '#666', marginLeft: 8 }, // muted
}); });
+4 -4
View File
@@ -6,7 +6,7 @@ import { useFocusEffect } from '@react-navigation/native';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native'; import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { discardOp, getProblemOps, ProblemOp, retryOp } from '../db/outbox'; import { discardOp, getProblemOps, ProblemOp, retryOp } from '../db/outbox';
import { Badge, Card, COLORS, EmptyState, PrimaryButton } from '../ui/components'; import { Badge, Card, EmptyState, PrimaryButton } from '../ui/components';
export function OutboxScreen() { export function OutboxScreen() {
const [ops, setOps] = useState<ProblemOp[]>([]); const [ops, setOps] = useState<ProblemOp[]>([]);
@@ -43,7 +43,7 @@ export function OutboxScreen() {
</Text> </Text>
<Badge <Badge
label={o.status} label={o.status}
color={o.status === 'conflict' ? COLORS.warn : COLORS.danger} color={o.status === 'conflict' ? '#8a6d00' : '#b00020'}
/> />
</View> </View>
{o.error ? <Text style={styles.error}>{o.error}</Text> : null} {o.error ? <Text style={styles.error}>{o.error}</Text> : null}
@@ -74,7 +74,7 @@ const styles = StyleSheet.create({
card: { gap: 8 }, card: { gap: 8 },
headerRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, headerRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
entity: { fontSize: 15, fontWeight: '700' }, entity: { fontSize: 15, fontWeight: '700' },
error: { color: COLORS.danger, fontSize: 13 }, error: { color: '#b00020', fontSize: 13 }, // danger
mono: { fontSize: 12, color: COLORS.muted, fontFamily: 'monospace' }, mono: { fontSize: 12, color: '#666', fontFamily: 'monospace' }, // muted
actions: { flexDirection: 'row', gap: 8, marginTop: 4 }, actions: { flexDirection: 'row', gap: 8, marginTop: 4 },
}); });
+83 -19
View File
@@ -22,10 +22,13 @@ import {
FooterConfig, FooterConfig,
FooterField, FooterField,
loadFooterConfig, loadFooterConfig,
OverlayPosition,
pickAndSaveLogo, pickAndSaveLogo,
PhotoResolution,
PhotoAspectRatio,
saveFooterConfig, saveFooterConfig,
} from '../photo/footerConfig'; } from '../photo/footerConfig';
import { COLORS, PrimaryButton, SectionTitle } from '../ui/components'; import { ChipSelect, PrimaryButton, SectionTitle } from '../ui/components';
const BUILT_IN_LABELS: Record<string, string> = { const BUILT_IN_LABELS: Record<string, string> = {
project_name: 'Nombre del proyecto', project_name: 'Nombre del proyecto',
@@ -33,6 +36,11 @@ const BUILT_IN_LABELS: Record<string, string> = {
coordinates: 'Coordenadas GPS', coordinates: 'Coordenadas GPS',
}; };
const RESOLUTION_OPTIONS: PhotoResolution[] = ['low', 'medium', 'high'];
const RATIO_OPTIONS: PhotoAspectRatio[] = ['1:1', '4:3', '16:9', 'full'];
const QUALITY_OPTIONS = ['0.5', '0.7', '0.85', '1.0'];
const POSITION_OPTIONS: OverlayPosition[] = ['bottom-left', 'bottom-right', 'top-left', 'top-right'];
export function PhotoSettingsScreen() { export function PhotoSettingsScreen() {
const [config, setConfig] = useState<FooterConfig | null>(null); const [config, setConfig] = useState<FooterConfig | null>(null);
const [newLabel, setNewLabel] = useState(''); const [newLabel, setNewLabel] = useState('');
@@ -119,13 +127,12 @@ export function PhotoSettingsScreen() {
return ( return (
<ScrollView contentContainerStyle={styles.body} keyboardShouldPersistTaps="handled"> <ScrollView contentContainerStyle={styles.body} keyboardShouldPersistTaps="handled">
{/* Master toggle */}
<View style={styles.row}> <View style={styles.row}>
<Text style={styles.masterLabel}>Añadir pie de página a las fotos</Text> <Text style={styles.masterLabel}>Añadir pie de página a las fotos</Text>
<Switch <Switch
value={config.enabled} value={config.enabled}
onValueChange={toggleEnabled} onValueChange={toggleEnabled}
trackColor={{ true: COLORS.primary }} trackColor={{ true: '#1f6f43' }} // primary
/> />
</View> </View>
@@ -154,6 +161,56 @@ export function PhotoSettingsScreen() {
Recorte cuadrado. Se recomienda logo con fondo blanco. Recorte cuadrado. Se recomienda logo con fondo blanco.
</Text> </Text>
{/* ── Resolución y Calidad ── */}
<SectionTitle>Calidad de imagen</SectionTitle>
<View style={styles.card}>
<ChipSelect
label="Resolución máxima"
value={config.resolution}
options={RESOLUTION_OPTIONS}
onChange={(v) => void save({ ...config, resolution: v })}
/>
<Text style={styles.hint}>
Low (~720p), Medium (~1080p), High (~4K).
</Text>
<View style={{ height: 12 }} />
<ChipSelect
label="Relación de aspecto"
value={config.aspectRatio}
options={RATIO_OPTIONS}
onChange={(v) => void save({ ...config, aspectRatio: v })}
/>
<Text style={styles.hint}>
Proporción de la foto (1:1 es cuadrada).
</Text>
<View style={{ height: 12 }} />
<ChipSelect
label="Calidad de compresión"
value={String(config.quality)}
options={QUALITY_OPTIONS}
onChange={(v) => void save({ ...config, quality: parseFloat(v) })}
/>
<Text style={styles.hint}>
Valores bajos reducen el tamaño del archivo pero pueden verse peor.
</Text>
<View style={{ height: 12 }} />
<ChipSelect
label="Posición del sello"
value={config.overlayPosition}
options={POSITION_OPTIONS}
onChange={(v) => void save({ ...config, overlayPosition: v })}
/>
<Text style={styles.hint}>
Esquina de la imagen donde aparecerán los datos.
</Text>
</View>
{/* ── Campos ── */} {/* ── Campos ── */}
<SectionTitle>Campos</SectionTitle> <SectionTitle>Campos</SectionTitle>
@@ -164,7 +221,7 @@ export function PhotoSettingsScreen() {
<Switch <Switch
value={f.enabled} value={f.enabled}
onValueChange={(v) => toggleField(f.id, v)} onValueChange={(v) => toggleField(f.id, v)}
trackColor={{ true: COLORS.primary }} trackColor={{ true: '#1f6f43' }} // primary
/> />
<View style={styles.fieldInfo}> <View style={styles.fieldInfo}>
<Text style={styles.fieldKey}> <Text style={styles.fieldKey}>
@@ -255,27 +312,34 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
paddingVertical: 14, paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
borderColor: COLORS.border, borderColor: '#ddd', // border
marginBottom: 8, marginBottom: 8,
}, },
masterLabel: { fontSize: 16, fontWeight: '600', flex: 1, marginRight: 12 }, masterLabel: { fontSize: 16, fontWeight: '600', flex: 1, marginRight: 12 },
/* Logo */ /* Logo */
logoRow: { flexDirection: 'row', alignItems: 'center', gap: 16, marginBottom: 4 }, logoRow: { flexDirection: 'row', alignItems: 'center', gap: 16, marginBottom: 4 },
logoPreview: { width: 72, height: 72, borderRadius: 8, backgroundColor: COLORS.bg }, logoPreview: { width: 72, height: 72, borderRadius: 8, backgroundColor: '#f4f4f4' }, // bg
logoPlaceholder: { logoPlaceholder: {
width: 72, width: 72,
height: 72, height: 72,
borderRadius: 8, borderRadius: 8,
backgroundColor: COLORS.bg, backgroundColor: '#f4f4f4', // bg
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
borderWidth: StyleSheet.hairlineWidth, borderWidth: StyleSheet.hairlineWidth,
borderColor: COLORS.border, borderColor: '#ddd', // border
}, },
logoPlaceholderText: { fontSize: 11, color: COLORS.muted }, logoPlaceholderText: { fontSize: 11, color: '#666' }, // muted
logoActions: { gap: 8 }, logoActions: { gap: 8 },
hint: { fontSize: 11, color: COLORS.muted, marginBottom: 8 }, hint: { fontSize: 11, color: '#666', marginBottom: 8 }, // muted
card: {
backgroundColor: '#f4f4f4', // bg
borderRadius: 8,
padding: 12,
marginBottom: 8,
},
/* Campos */ /* Campos */
fieldRow: { fieldRow: {
@@ -284,21 +348,21 @@ const styles = StyleSheet.create({
gap: 10, gap: 10,
paddingVertical: 10, paddingVertical: 10,
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
borderColor: COLORS.border, borderColor: '#ddd', // border
}, },
fieldInfo: { flex: 1 }, fieldInfo: { flex: 1 },
fieldKey: { fontSize: 11, color: COLORS.muted, marginBottom: 2 }, fieldKey: { fontSize: 11, color: '#666', marginBottom: 2 }, // muted
fieldLabel: { fieldLabel: {
fontSize: 14, fontSize: 14,
color: '#111', color: '#111',
borderBottomWidth: 1, borderBottomWidth: 1,
borderColor: COLORS.border, borderColor: '#ddd', // border
paddingVertical: 2, paddingVertical: 2,
paddingHorizontal: 0, paddingHorizontal: 0,
}, },
fieldValue: { marginTop: 4, color: COLORS.muted }, fieldValue: { marginTop: 4, color: '#666' }, // muted
deleteBtn: { padding: 8 }, deleteBtn: { padding: 8 },
deleteTxt: { color: COLORS.danger, fontSize: 16 }, deleteTxt: { color: '#b00020', fontSize: 16 }, // danger
/* Nuevo campo */ /* Nuevo campo */
addFieldBtn: { addFieldBtn: {
@@ -307,20 +371,20 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
borderWidth: 1, borderWidth: 1,
borderStyle: 'dashed', borderStyle: 'dashed',
borderColor: COLORS.primary, borderColor: '#1f6f43', // primary
borderRadius: 8, borderRadius: 8,
}, },
addFieldTxt: { color: COLORS.primary, fontWeight: '600' }, addFieldTxt: { color: '#1f6f43', fontWeight: '600' }, // primary
addForm: { addForm: {
marginTop: 12, marginTop: 12,
padding: 12, padding: 12,
backgroundColor: COLORS.bg, backgroundColor: '#f4f4f4', // bg
borderRadius: 8, borderRadius: 8,
gap: 10, gap: 10,
}, },
addInput: { addInput: {
borderWidth: 1, borderWidth: 1,
borderColor: COLORS.border, borderColor: '#ddd', // border
borderRadius: 6, borderRadius: 6,
paddingHorizontal: 10, paddingHorizontal: 10,
paddingVertical: 8, paddingVertical: 8,
+20 -10
View File
@@ -7,8 +7,9 @@ import { getOutboxCounts, OutboxCounts } from '../db/outbox';
import { isOnline } from '../net/connectivity'; import { isOnline } from '../net/connectivity';
import { runSync } from '../sync/engine'; import { runSync } from '../sync/engine';
import { useAutoSync } from '../sync/useAutoSync'; import { useAutoSync } from '../sync/useAutoSync';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { RootStackParamList } from '../navigation/types'; import { RootStackParamList } from '../navigation/types';
import { COLORS, PrimaryButton } from '../ui/components'; import { PrimaryButton } from '../ui/components';
import { FeaturesSection } from './sections/FeaturesSection'; import { FeaturesSection } from './sections/FeaturesSection';
import { IssuesSection } from './sections/IssuesSection'; import { IssuesSection } from './sections/IssuesSection';
@@ -19,8 +20,10 @@ const TABS = ['Features', 'Incidencias'] as const;
type Tab = (typeof TABS)[number]; type Tab = (typeof TABS)[number];
export function ProjectDetailScreen({ route, navigation }: Props) { export function ProjectDetailScreen({ route, navigation }: Props) {
const insets = useSafeAreaInsets();
const { projectId } = route.params; const { projectId } = route.params;
const [tab, setTab] = useState<Tab>('Features'); const [tab, setTab] = useState<Tab>('Features');
const [featuresMode, setFeaturesMode] = useState<'list' | 'map'>('list');
const [counts, setCounts] = useState<OutboxCounts>(EMPTY); const [counts, setCounts] = useState<OutboxCounts>(EMPTY);
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
// Se incrementa tras cada sync para forzar el recargado de la sección visible. // Se incrementa tras cada sync para forzar el recargado de la sección visible.
@@ -102,12 +105,19 @@ export function ProjectDetailScreen({ route, navigation }: Props) {
))} ))}
</View> </View>
<View style={styles.content} key={`${tab}-${nonce}`}> <View style={styles.content} key={tab}>
{tab === 'Features' && <FeaturesSection projectId={projectId} />} {tab === 'Features' && (
{tab === 'Incidencias' && <IssuesSection projectId={projectId} />} <FeaturesSection
projectId={projectId}
refreshKey={nonce}
mode={featuresMode}
onModeChange={setFeaturesMode}
/>
)}
{tab === 'Incidencias' && <IssuesSection projectId={projectId} refreshKey={nonce} />}
</View> </View>
<View style={styles.footer}> <View style={[styles.footer, { paddingBottom: Math.max(12, insets.bottom) }]}>
<PrimaryButton <PrimaryButton
title={syncing ? 'Sincronizando…' : 'Sincronizar'} title={syncing ? 'Sincronizando…' : 'Sincronizar'}
onPress={() => void onSync()} onPress={() => void onSync()}
@@ -120,11 +130,11 @@ export function ProjectDetailScreen({ route, navigation }: Props) {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1 }, container: { flex: 1 },
tabs: { flexDirection: 'row', borderBottomWidth: StyleSheet.hairlineWidth, borderColor: COLORS.border }, tabs: { flexDirection: 'row', borderBottomWidth: StyleSheet.hairlineWidth, borderColor: '#ddd' }, // border
tab: { flex: 1, paddingVertical: 12, alignItems: 'center' }, tab: { flex: 1, paddingVertical: 12, alignItems: 'center' },
tabActive: { borderBottomWidth: 2, borderColor: COLORS.primary }, tabActive: { borderBottomWidth: 2, borderColor: '#1f6f43' }, // primary
tabText: { fontSize: 14, color: COLORS.muted }, tabText: { fontSize: 14, color: '#666' }, // muted
tabTextActive: { color: COLORS.primary, fontWeight: '700' }, tabTextActive: { color: '#1f6f43', fontWeight: '700' }, // primary
content: { flex: 1 }, content: { flex: 1 },
footer: { padding: 12, borderTopWidth: StyleSheet.hairlineWidth, borderColor: COLORS.border }, footer: { padding: 12, borderTopWidth: StyleSheet.hairlineWidth, borderColor: '#ddd' }, // border
}); });
+132 -50
View File
@@ -1,5 +1,5 @@
import { NativeStackScreenProps } from '@react-navigation/native-stack'; import { NativeStackScreenProps } from '@react-navigation/native-stack';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
FlatList, FlatList,
@@ -17,6 +17,8 @@ import { isOnline } from '../net/connectivity';
import { runSync } from '../sync/engine'; import { runSync } from '../sync/engine';
import { RootStackParamList } from '../navigation/types'; import { RootStackParamList } from '../navigation/types';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
type Props = NativeStackScreenProps<RootStackParamList, 'Projects'>; type Props = NativeStackScreenProps<RootStackParamList, 'Projects'>;
/** Tolerante con la forma de la respuesta: `{projects}` (actual), `{data}` o array. */ /** Tolerante con la forma de la respuesta: `{projects}` (actual), `{data}` o array. */
@@ -27,10 +29,32 @@ function normalize(res: unknown): Project[] {
} }
export function ProjectsScreen({ navigation }: Props) { export function ProjectsScreen({ navigation }: Props) {
const insets = useSafeAreaInsets();
const { user, signOut } = useSession(); const { user, signOut } = useSession();
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [opening, setOpening] = useState<number | null>(null); const [opening, setOpening] = useState<number | null>(null);
const [showMenu, setShowMenu] = useState(false);
// Mueve el menú de usuario al header nativo
useLayoutEffect(() => {
navigation.setOptions({
headerRight: () => (
<View style={styles.headerRight}>
<TouchableOpacity
onPress={() => setShowMenu(!showMenu)}
activeOpacity={0.7}
style={styles.userButton}
>
<Text style={styles.dropdownLabel}>
{user?.name?.split(' ')[0] ?? 'Usuario'}
<Text style={styles.chevronSmall}> </Text>
</Text>
</TouchableOpacity>
</View>
),
});
}, [navigation, user, showMenu]);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -38,9 +62,6 @@ export function ProjectsScreen({ navigation }: Props) {
if (await isOnline()) { if (await isOnline()) {
const list = normalize(await api.listProjects()); const list = normalize(await api.listProjects());
await saveProjectList(list); await saveProjectList(list);
// Prefetch del catálogo global de plantillas: así quedan SIEMPRE en
// local y se puede inspeccionar offline aunque el proyecto no se haya
// abierto todavía. No bloquea la lista si /templates falla.
try { try {
const { templates } = await api.getTemplates(); const { templates } = await api.getTemplates();
await saveTemplates(templates); await saveTemplates(templates);
@@ -66,8 +87,6 @@ export function ProjectsScreen({ navigation }: Props) {
setOpening(p.id); setOpening(p.id);
try { try {
await setActiveProjectId(p.id); await setActiveProjectId(p.id);
// runSync ya resuelve ambos casos: sin cursor baja el snapshot completo,
// con cursor baja el delta (y vacía primero el outbox, vacío en la 1ª vez).
if (await isOnline()) await runSync(p.id); if (await isOnline()) await runSync(p.id);
navigation.navigate('ProjectDetail', { projectId: p.id, name: p.name }); navigation.navigate('ProjectDetail', { projectId: p.id, name: p.name });
} finally { } finally {
@@ -77,60 +96,123 @@ export function ProjectsScreen({ navigation }: Props) {
[navigation], [navigation],
); );
return ( const handleLogout = () => {
<View style={styles.container}> setShowMenu(false);
<View style={styles.header}> signOut();
<Text style={styles.hello}>Hola, {user?.name ?? ''}</Text> };
<TouchableOpacity onPress={signOut}>
<Text style={styles.logout}>Salir</Text>
</TouchableOpacity>
</View>
<FlatList const handleSettings = () => {
data={projects} setShowMenu(false);
keyExtractor={(p) => String(p.id)} navigation.navigate('Settings');
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />} };
ListEmptyComponent={
loading ? null : <Text style={styles.empty}>No hay proyectos.</Text> return (
} <View style={[styles.container, { paddingBottom: insets.bottom }]}>
renderItem={({ item }) => ( {/* Menu flotante (se posiciona relativo al contenedor principal) */}
<TouchableOpacity style={styles.row} onPress={() => openProject(item)}> {showMenu && (
<View style={{ flex: 1 }}> <View style={styles.floatingMenu}>
<Text style={styles.name}>{item.name}</Text> <TouchableOpacity style={styles.dropdownItem} onPress={handleSettings} activeOpacity={0.7}>
{item.reference ? <Text style={styles.ref}>{item.reference}</Text> : null} <Text style={styles.dropdownItemText}> Configuración</Text>
</View>
{opening === item.id ? (
<ActivityIndicator />
) : (
<Text style={styles.chevron}></Text>
)}
</TouchableOpacity> </TouchableOpacity>
)} <TouchableOpacity style={[styles.dropdownItem, styles.dropdownItemDanger]} onPress={handleLogout} activeOpacity={0.7}>
/> <Text style={styles.dropdownItemText}>Salir</Text>
</TouchableOpacity>
</View>
)}
{/* Close dropdown when tapping outside */}
<TouchableOpacity
onPress={() => setShowMenu(false)}
style={styles.touchOutside}
activeOpacity={1}
>
<FlatList
data={projects}
keyExtractor={(p) => String(p.id)}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
ListEmptyComponent={loading ? null : <Text style={styles.empty}>No hay proyectos.</Text>}
renderItem={({ item }) => (
<TouchableOpacity style={styles.card} onPress={() => openProject(item)} activeOpacity={0.7}>
<View style={styles.cardContent}>
<Text style={styles.cardName}>{item.name}</Text>
{item.reference ? <Text style={styles.cardMeta}>Ref: {item.reference}</Text> : null}
{item.address ? <Text style={styles.cardMeta}>📍 {item.address}</Text> : null}
{item.status ? <Text style={styles.cardMeta}>Estado: {item.status}</Text> : null}
</View>
{opening === item.id ? (
<ActivityIndicator />
) : (
<Text style={styles.chevron}></Text>
)}
</TouchableOpacity>
)}
/>
</TouchableOpacity>
</View> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1 }, container: { flex: 1, backgroundColor: '#fff' },
header: { headerRight: {
flexDirection: 'row', marginRight: 8,
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
}, },
hello: { fontSize: 16, fontWeight: '600' }, userButton: {
logout: { color: '#b00020', fontWeight: '600' }, paddingHorizontal: 8,
row: { paddingVertical: 4,
flexDirection: 'row', },
alignItems: 'center', dropdownLabel: {
fontSize: 15,
fontWeight: '600',
color: '#1f6f43', // primary
},
chevronSmall: { fontSize: 10, color: '#1f6f43' }, // primary
floatingMenu: {
position: 'absolute',
top: 4,
right: 16,
backgroundColor: '#fff',
borderRadius: 8,
borderWidth: 1,
borderColor: '#ddd', // border
overflow: 'hidden',
minWidth: 180,
elevation: 10,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 5,
zIndex: 1000,
},
dropdownItem: {
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 14, paddingVertical: 14,
borderTopWidth: StyleSheet.hairlineWidth,
borderColor: '#ddd',
}, },
name: { fontSize: 16, fontWeight: '600' }, dropdownItemDanger: {
ref: { fontSize: 13, color: '#666', marginTop: 2 }, borderTopWidth: StyleSheet.hairlineWidth,
chevron: { fontSize: 24, color: '#999' }, borderTopColor: '#ddd', // border
},
dropdownItemText: {
fontSize: 15,
color: '#333',
},
touchOutside: {
flex: 1,
},
card: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 18,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#ddd', // border
backgroundColor: '#fff',
},
cardContent: { flex: 1 },
cardName: { fontSize: 16, fontWeight: '700', color: '#111' },
cardMeta: { fontSize: 13, color: '#666', marginTop: 4 }, // muted
chevron: { fontSize: 20, color: '#ddd', marginLeft: 8 }, // border
empty: { textAlign: 'center', marginTop: 40, color: '#888' }, empty: { textAlign: 'center', marginTop: 40, color: '#888' },
}); });
+174
View File
@@ -0,0 +1,174 @@
/**
* Pantalla de Configuración.
* Incluye: Configuración de usuario (idioma) y acceso a Configuración de fotos.
*/
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import React, { useCallback, useEffect, useState } from 'react';
import {
Alert,
ScrollView,
StyleSheet,
Switch,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { RootStackParamList } from '../navigation/types';
import { PrimaryButton, SectionTitle } from '../ui/components';
import { useSession } from '../auth/session';
type Nav = NativeStackNavigationProp<RootStackParamList>;
export function SettingsScreen({ navigation }: NativeStackScreenProps<RootStackParamList, 'Settings'>) {
const { user } = useSession();
const [language, setLanguage] = useState<string>('en');
useEffect(() => {
// Cargar idioma guardado (en un futuro usar AsyncStorage)
// Por ahora, por defecto inglés
setLanguage('en');
}, []);
const handleLanguageChange = (lang: string) => {
setLanguage(lang);
// TODO: Guardar en AsyncStorage y aplicar cambio de idioma
Alert.alert('Idioma', `Idioma cambiado a ${lang === 'en' ? 'Inglés' : 'Español'}. Reinicia la app para aplicar.`);
};
return (
<ScrollView contentContainerStyle={styles.body}>
<SectionTitle>Configuración de usuario</SectionTitle>
<View style={styles.card}>
<View style={styles.row}>
<Text style={styles.label}>Idioma de la app</Text>
</View>
<View style={styles.languageSelector}>
<TouchableOpacity
style={[styles.langBtn, language === 'en' && styles.langBtnActive]}
onPress={() => handleLanguageChange('en')}
>
<Text style={[styles.langBtnText, language === 'en' && styles.langBtnTextActive]}>English</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.langBtn, language === 'es' && styles.langBtnActive]}
onPress={() => handleLanguageChange('es')}
>
<Text style={[styles.langBtnText, language === 'es' && styles.langBtnTextActive]}>Español</Text>
</TouchableOpacity>
</View>
<Text style={styles.hint}>Por defecto: English</Text>
</View>
<View style={styles.divider} />
<SectionTitle>Configuración de fotos</SectionTitle>
<View style={styles.card}>
<TouchableOpacity
style={styles.settingsRow}
onPress={() => navigation.navigate('PhotoSettings')}
>
<View style={styles.settingsRowContent}>
<Text style={styles.settingsIcon}>📷</Text>
<Text style={styles.settingsLabel}>Configuración del pie de página</Text>
</View>
<Text style={styles.chevron}></Text>
</TouchableOpacity>
<Text style={styles.hint}>Personaliza logo, campos (proyecto, fecha, coordenadas) y campos personalizados</Text>
</View>
<View style={styles.divider} />
<SectionTitle>Cuenta</SectionTitle>
<View style={styles.card}>
<View style={styles.accountRow}>
<Text style={styles.accountLabel}>Usuario actual</Text>
<Text style={styles.accountValue}>{user?.name ?? 'Desconocido'}</Text>
</View>
<View style={styles.accountRow}>
<Text style={styles.accountLabel}>Email</Text>
<Text style={styles.accountValue}>{user?.email ?? ''}</Text>
</View>
</View>
<View style={styles.divider} />
<SectionTitle>Acerca de</SectionTitle>
<View style={styles.card}>
<View style={styles.accountRow}>
<Text style={styles.accountLabel}>Versión</Text>
<Text style={styles.accountValue}>1.0.0</Text>
</View>
<View style={styles.accountRow}>
<Text style={styles.accountLabel}>Build</Text>
<Text style={styles.accountValue}>Avante Móvil</Text>
</View>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
body: { padding: 16, gap: 16 },
card: {
backgroundColor: '#fff',
borderRadius: 10,
padding: 16,
borderWidth: 1,
borderColor: '#ddd', // border
},
row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
label: { fontSize: 15, fontWeight: '600', color: '#111' },
hint: { fontSize: 12, color: '#666', marginTop: 4 }, // muted
languageSelector: {
flexDirection: 'row',
gap: 8,
marginTop: 8,
},
langBtn: {
flex: 1,
paddingVertical: 10,
paddingHorizontal: 16,
borderRadius: 8,
borderWidth: 1,
borderColor: '#ddd', // border
backgroundColor: '#fff',
alignItems: 'center',
},
langBtnActive: {
backgroundColor: '#1f6f43', // primary
borderColor: '#1f6f43', // primary
},
langBtnText: { fontSize: 14, color: '#666' }, // muted
langBtnTextActive: { color: '#fff', fontWeight: '600' },
divider: { height: 1, backgroundColor: '#ddd', marginVertical: 8 }, // border
settingsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 4,
},
settingsRowContent: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
settingsIcon: { fontSize: 20 },
settingsLabel: { fontSize: 15, fontWeight: '500', color: '#111' },
chevron: { fontSize: 20, color: '#666' }, // muted
accountRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#ddd', // border
},
accountLabel: { fontSize: 14, color: '#666' }, // muted
accountValue: { fontSize: 14, fontWeight: '600', color: '#111', textAlign: 'right', flex: 1, marginLeft: 16 },
});
+51 -122
View File
@@ -1,33 +1,23 @@
/**
* Contenido del detalle de una feature: estado/progreso editable, inspecciones
* y fotos. Reutilizado en móvil (pantalla) y tablet (panel del maestro-detalle).
*/
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import React, { useCallback, useEffect, useState } from 'react';
import { ScrollView, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
import { Feature, FeatureType, Inspection } from '../../api/types'; import { Feature, FeatureType, Inspection } from '../../api/types';
import { hasPermission, useSession } from '../../auth/session'; import { hasPermission, useSession } from '../../auth/session';
import { getFeature, getFeatureTypes, getInspectionsByFeature } from '../../db/repositories'; import { getFeature, getFeatureTypes, getInspectionsByFeature } from '../../db/repositories';
import { updateFeature } from '../../sync/mutations'; import { useFocusEffect, useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import React, { useCallback, useEffect, useState } from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { RootStackParamList } from '../../navigation/types'; import { RootStackParamList } from '../../navigation/types';
import { import {
Badge, Badge,
Card, Card,
ChipSelect,
COLORS,
EmptyState, EmptyState,
PrimaryButton,
SectionTitle, SectionTitle,
} from '../../ui/components'; } from '../../ui/components';
import { MediaStrip } from '../../ui/MediaStrip';
const STATUSES = ['pending', 'in_progress', 'completed', 'blocked'] as const;
const QUICK_PROGRESS = [0, 25, 50, 75, 100];
type Nav = NativeStackNavigationProp<RootStackParamList>; type Nav = NativeStackNavigationProp<RootStackParamList>;
export function FeatureDetailContent({ featureId }: { featureId: number }) { export function FeatureDetailContent({ featureId }: { featureId: number }) {
const insets = useSafeAreaInsets();
const navigation = useNavigation<Nav>(); const navigation = useNavigation<Nav>();
const { user } = useSession(); const { user } = useSession();
const canProgress = hasPermission(user, 'update progress'); const canProgress = hasPermission(user, 'update progress');
@@ -37,6 +27,34 @@ export function FeatureDetailContent({ featureId }: { featureId: number }) {
const [inspections, setInspections] = useState<Inspection[]>([]); const [inspections, setInspections] = useState<Inspection[]>([]);
const [featureTypes, setFeatureTypes] = useState<FeatureType[]>([]); const [featureTypes, setFeatureTypes] = useState<FeatureType[]>([]);
// Header right button for Nueva inspección
// MOVIDO AQUÍ: Los hooks deben ir siempre antes de cualquier return.
useFocusEffect(
useCallback(() => {
if (canInspect && feature) {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity
style={styles.headerButton}
onPress={() =>
navigation.navigate('InspectionForm', {
featureId: feature.id,
featureName: feature.name,
templateId: feature.template_id ?? undefined,
})
}
>
<Text style={styles.headerButtonText}>+ Inspección</Text>
</TouchableOpacity>
),
});
}
return () => {
navigation.setOptions({ headerRight: undefined });
};
}, [navigation, canInspect, feature]),
);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
const [f, ins, types] = await Promise.all([ const [f, ins, types] = await Promise.all([
getFeature(featureId), getFeature(featureId),
@@ -52,34 +70,10 @@ export function FeatureDetailContent({ featureId }: { featureId: number }) {
void refresh(); void refresh();
}, [refresh]); }, [refresh]);
const onStatus = useCallback(
async (status: string) => {
await updateFeature({ id: featureId, status });
await refresh();
},
[featureId, refresh],
);
const onProgress = useCallback(
async (progress: number) => {
await updateFeature({ id: featureId, progress });
await refresh();
},
[featureId, refresh],
);
const onToggleActive = useCallback(
async (active: boolean) => {
await updateFeature({ id: featureId, is_active: active });
await refresh();
},
[featureId, refresh],
);
if (!feature) { if (!feature) {
return ( return (
<View style={styles.center}> <View style={styles.center}>
<Text style={{ color: COLORS.muted }}>Cargando</Text> <Text style={{ color: '#666' }}>Cargando</Text>
</View> </View>
); );
} }
@@ -88,57 +82,14 @@ export function FeatureDetailContent({ featureId }: { featureId: number }) {
const isActive = feature.is_active == null || Number(feature.is_active) !== 0; const isActive = feature.is_active == null || Number(feature.is_active) !== 0;
return ( return (
<ScrollView contentContainerStyle={styles.body}> <ScrollView contentContainerStyle={[styles.body, { paddingBottom: insets.bottom + 20 }]}>
<Text style={styles.title}>{feature.name}</Text>
<View style={styles.badges}> <View style={styles.badges}>
{featureType && ( {featureType && (
<Badge label={featureType.name} color={featureType.color ?? COLORS.muted} /> <Badge label={featureType.name} color={featureType.color ?? '#666'} />
)} )}
{feature.status && <Badge label={feature.status} color={COLORS.primary} />} <Badge label={`${Math.round(feature.progress ?? 0)}%`} color={'#666'} />
<Badge label={`${Math.round(feature.progress ?? 0)}%`} color={COLORS.muted} />
{!isActive && <Badge label="inactiva" color={COLORS.danger} />}
</View> </View>
<MediaStrip
parentEntity="feature"
parentId={feature.id}
canUpload={hasPermission(user, 'upload media')}
/>
{canProgress && (
<Card style={styles.editCard}>
<ChipSelect
label="Estado"
value={feature.status as (typeof STATUSES)[number] | undefined}
options={STATUSES}
onChange={(v) => void onStatus(v)}
/>
<Text style={styles.fieldLabel}>Progreso</Text>
<View style={styles.progressRow}>
{QUICK_PROGRESS.map((p) => {
const active = Math.round(feature.progress ?? 0) === p;
return (
<TouchableOpacity
key={p}
style={[styles.pBtn, active && styles.pBtnActive]}
onPress={() => void onProgress(p)}
>
<Text style={[styles.pText, active && styles.pTextActive]}>{p}%</Text>
</TouchableOpacity>
);
})}
</View>
<View style={styles.activeRow}>
<Text style={styles.fieldLabel}>Activa</Text>
<Switch
value={isActive}
onValueChange={(v) => void onToggleActive(v)}
trackColor={{ true: COLORS.primary }}
/>
</View>
</Card>
)}
<SectionTitle>Inspecciones ({inspections.length})</SectionTitle> <SectionTitle>Inspecciones ({inspections.length})</SectionTitle>
{inspections.length === 0 && <EmptyState text="Sin inspecciones." />} {inspections.length === 0 && <EmptyState text="Sin inspecciones." />}
{inspections.map((ins) => ( {inspections.map((ins) => (
@@ -150,19 +101,6 @@ export function FeatureDetailContent({ featureId }: { featureId: number }) {
{ins.notes ? <Text style={styles.insNotes}>{ins.notes}</Text> : null} {ins.notes ? <Text style={styles.insNotes}>{ins.notes}</Text> : null}
</Card> </Card>
))} ))}
{canInspect && (
<PrimaryButton
title="Nueva inspección"
variant="ghost"
onPress={() =>
navigation.navigate('InspectionForm', {
featureId: feature.id,
featureName: feature.name,
templateId: feature.template_id ?? undefined,
})
}
/>
)}
</ScrollView> </ScrollView>
); );
} }
@@ -170,29 +108,20 @@ export function FeatureDetailContent({ featureId }: { featureId: number }) {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
center: { flex: 1, justifyContent: 'center', alignItems: 'center' }, center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
body: { padding: 16, gap: 6 }, body: { padding: 16, gap: 6 },
title: { fontSize: 20, fontWeight: '700' },
badges: { flexDirection: 'row', gap: 6, marginTop: 6 }, badges: { flexDirection: 'row', gap: 6, marginTop: 6 },
editCard: { marginTop: 12 },
fieldLabel: { fontSize: 13, color: COLORS.muted, marginBottom: 6, fontWeight: '600' },
progressRow: { flexDirection: 'row', gap: 6, flexWrap: 'wrap' },
activeRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 12,
},
pBtn: {
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 8,
borderWidth: 1,
borderColor: COLORS.border,
backgroundColor: '#fff',
},
pBtnActive: { backgroundColor: COLORS.primary, borderColor: COLORS.primary },
pText: { color: COLORS.muted, fontWeight: '600' },
pTextActive: { color: '#fff' },
insCard: { marginBottom: 6 }, insCard: { marginBottom: 6 },
insTitle: { fontSize: 14, fontWeight: '600' }, insTitle: { fontSize: 14, fontWeight: '600' },
insNotes: { fontSize: 13, color: COLORS.muted, marginTop: 2 }, insNotes: { fontSize: 13, color: '#666', marginTop: 2 },
headerButton: {
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: '#1f6f43',
borderRadius: 6,
marginRight: 8,
},
headerButtonText: {
color: '#fff',
fontWeight: '600',
fontSize: 13,
},
}); });
+6 -7
View File
@@ -30,7 +30,6 @@ import {
Badge, Badge,
Card, Card,
ChipSelect, ChipSelect,
COLORS,
EmptyState, EmptyState,
Field, Field,
ISSUE_PRIORITY_COLOR, ISSUE_PRIORITY_COLOR,
@@ -111,7 +110,7 @@ export function IssueDetailContent({ issueId }: { issueId: number }) {
if (!issue) { if (!issue) {
return ( return (
<View style={styles.center}> <View style={styles.center}>
<Text style={{ color: COLORS.muted }}>Cargando</Text> <Text style={{ color: '#666' }}>Cargando</Text>
</View> </View>
); );
} }
@@ -124,15 +123,15 @@ export function IssueDetailContent({ issueId }: { issueId: number }) {
<Text style={styles.title}>{issue.title}</Text> <Text style={styles.title}>{issue.title}</Text>
<View style={styles.badges}> <View style={styles.badges}>
{issue.status && ( {issue.status && (
<Badge label={issue.status} color={ISSUE_STATUS_COLOR[issue.status] ?? COLORS.muted} /> <Badge label={issue.status} color={ISSUE_STATUS_COLOR[issue.status] ?? '#666'} />
)} )}
{issue.priority && ( {issue.priority && (
<Badge <Badge
label={issue.priority} label={issue.priority}
color={ISSUE_PRIORITY_COLOR[issue.priority] ?? COLORS.muted} color={ISSUE_PRIORITY_COLOR[issue.priority] ?? '#666'}
/> />
)} )}
{issue.type && <Badge label={issue.type} color={COLORS.muted} />} {issue.type && <Badge label={issue.type} color="#666" />}
</View> </View>
{issue.description ? <Text style={styles.desc}>{issue.description}</Text> : null} {issue.description ? <Text style={styles.desc}>{issue.description}</Text> : null}
@@ -225,9 +224,9 @@ const styles = StyleSheet.create({
taskRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, gap: 10 }, taskRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, gap: 10 },
checkbox: { fontSize: 20 }, checkbox: { fontSize: 20 },
taskText: { fontSize: 15, flex: 1 }, taskText: { fontSize: 15, flex: 1 },
taskDone: { textDecorationLine: 'line-through', color: COLORS.muted }, taskDone: { textDecorationLine: 'line-through', color: '#666' }, // muted
addRow: { flexDirection: 'row', alignItems: 'flex-end', gap: 8, marginTop: 4 }, addRow: { flexDirection: 'row', alignItems: 'flex-end', gap: 8, marginTop: 4 },
comment: { marginBottom: 6 }, comment: { marginBottom: 6 },
commentBody: { fontSize: 14 }, commentBody: { fontSize: 14 },
commentMeta: { fontSize: 11, color: COLORS.muted, marginTop: 4 }, commentMeta: { fontSize: 11, color: '#666', marginTop: 4 }, // muted
}); });
+27 -12
View File
@@ -5,12 +5,12 @@
*/ */
import { useFocusEffect, useNavigation } from '@react-navigation/native'; import { useFocusEffect, useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Feature } from '../../api/types'; import { Feature } from '../../api/types';
import { getFeatures } from '../../db/repositories'; import { getFeatures } from '../../db/repositories';
import { RootStackParamList } from '../../navigation/types'; import { RootStackParamList } from '../../navigation/types';
import { Badge, COLORS, EmptyState } from '../../ui/components'; import { Badge, EmptyState } from '../../ui/components';
import { FeatureMap } from '../../ui/FeatureMap'; import { FeatureMap } from '../../ui/FeatureMap';
import { MasterDetail } from '../../ui/MasterDetail'; import { MasterDetail } from '../../ui/MasterDetail';
import { FeatureDetailContent } from '../detail/FeatureDetailContent'; import { FeatureDetailContent } from '../detail/FeatureDetailContent';
@@ -18,10 +18,19 @@ import { FeatureDetailContent } from '../detail/FeatureDetailContent';
type Nav = NativeStackNavigationProp<RootStackParamList>; type Nav = NativeStackNavigationProp<RootStackParamList>;
type ViewMode = 'list' | 'map'; type ViewMode = 'list' | 'map';
export function FeaturesSection({ projectId }: { projectId: number }) { export function FeaturesSection({
projectId,
refreshKey,
mode,
onModeChange,
}: {
projectId: number;
refreshKey?: number;
mode: ViewMode;
onModeChange: (m: ViewMode) => void;
}) {
const navigation = useNavigation<Nav>(); const navigation = useNavigation<Nav>();
const [features, setFeatures] = useState<Feature[]>([]); const [features, setFeatures] = useState<Feature[]>([]);
const [mode, setMode] = useState<ViewMode>('list');
const load = useCallback(() => { const load = useCallback(() => {
void getFeatures(projectId).then(setFeatures); void getFeatures(projectId).then(setFeatures);
@@ -29,6 +38,12 @@ export function FeaturesSection({ projectId }: { projectId: number }) {
useFocusEffect(load); useFocusEffect(load);
// Refrescar datos cuando cambie la refreshKey (p.ej. tras un sync automático)
// sin desmontar el componente (preservando el modo lista/mapa).
useEffect(() => {
if (refreshKey) void load();
}, [refreshKey, load]);
const goPhone = (id: number) => { const goPhone = (id: number) => {
const f = features.find((x) => x.id === id); const f = features.find((x) => x.id === id);
navigation.navigate('FeatureDetail', { featureId: id, name: f?.name ?? 'Feature' }); navigation.navigate('FeatureDetail', { featureId: id, name: f?.name ?? 'Feature' });
@@ -45,7 +60,7 @@ export function FeaturesSection({ projectId }: { projectId: number }) {
<TouchableOpacity <TouchableOpacity
key={m} key={m}
style={[styles.toggleBtn, mode === m && styles.toggleActive]} style={[styles.toggleBtn, mode === m && styles.toggleActive]}
onPress={() => setMode(m)} onPress={() => onModeChange(m)}
> >
<Text style={[styles.toggleText, mode === m && styles.toggleTextActive]}> <Text style={[styles.toggleText, mode === m && styles.toggleTextActive]}>
{m === 'list' ? 'Lista' : 'Mapa'} {m === 'list' ? 'Lista' : 'Mapa'}
@@ -71,7 +86,7 @@ export function FeaturesSection({ projectId }: { projectId: number }) {
<Text style={styles.name}>{item.name}</Text> <Text style={styles.name}>{item.name}</Text>
{item.status ? <Text style={styles.meta}>{item.status}</Text> : null} {item.status ? <Text style={styles.meta}>{item.status}</Text> : null}
</View> </View>
<Badge label={`${Math.round(item.progress ?? 0)}%`} color={COLORS.muted} /> <Badge label={`${Math.round(item.progress ?? 0)}%`} color="#666" />
</TouchableOpacity> </TouchableOpacity>
)} )}
/> />
@@ -88,17 +103,17 @@ const styles = StyleSheet.create({
padding: 8, padding: 8,
gap: 8, gap: 8,
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
borderColor: COLORS.border, borderColor: '#ddd', // border
}, },
toggleBtn: { toggleBtn: {
paddingHorizontal: 14, paddingHorizontal: 14,
paddingVertical: 6, paddingVertical: 6,
borderRadius: 16, borderRadius: 16,
borderWidth: 1, borderWidth: 1,
borderColor: COLORS.border, borderColor: '#ddd', // border
}, },
toggleActive: { backgroundColor: COLORS.primary, borderColor: COLORS.primary }, toggleActive: { backgroundColor: '#1f6f43', borderColor: '#1f6f43' }, // primary
toggleText: { fontSize: 13, color: COLORS.muted }, toggleText: { fontSize: 13, color: '#666' }, // muted
toggleTextActive: { color: '#fff', fontWeight: '700' }, toggleTextActive: { color: '#fff', fontWeight: '700' },
row: { row: {
flexDirection: 'row', flexDirection: 'row',
@@ -106,10 +121,10 @@ const styles = StyleSheet.create({
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 14, paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
borderColor: COLORS.border, borderColor: '#ddd', // border
gap: 8, gap: 8,
}, },
rowActive: { backgroundColor: '#eef5f0' }, rowActive: { backgroundColor: '#eef5f0' },
name: { fontSize: 15, fontWeight: '600' }, name: { fontSize: 15, fontWeight: '600' },
meta: { fontSize: 12, color: COLORS.muted, marginTop: 2 }, meta: { fontSize: 12, color: '#666', marginTop: 2 }, // muted
}); });
+11 -8
View File
@@ -3,7 +3,7 @@
*/ */
import { useFocusEffect, useNavigation } from '@react-navigation/native'; import { useFocusEffect, useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Issue } from '../../api/types'; import { Issue } from '../../api/types';
import { hasPermission, useSession } from '../../auth/session'; import { hasPermission, useSession } from '../../auth/session';
@@ -11,7 +11,6 @@ import { getIssues } from '../../db/repositories';
import { RootStackParamList } from '../../navigation/types'; import { RootStackParamList } from '../../navigation/types';
import { import {
Badge, Badge,
COLORS,
EmptyState, EmptyState,
ISSUE_PRIORITY_COLOR, ISSUE_PRIORITY_COLOR,
ISSUE_STATUS_COLOR, ISSUE_STATUS_COLOR,
@@ -22,7 +21,7 @@ import { IssueDetailContent } from '../detail/IssueDetailContent';
type Nav = NativeStackNavigationProp<RootStackParamList>; type Nav = NativeStackNavigationProp<RootStackParamList>;
export function IssuesSection({ projectId }: { projectId: number }) { export function IssuesSection({ projectId, refreshKey }: { projectId: number; refreshKey?: number }) {
const navigation = useNavigation<Nav>(); const navigation = useNavigation<Nav>();
const { user } = useSession(); const { user } = useSession();
const canCreate = hasPermission(user, 'create issues'); const canCreate = hasPermission(user, 'create issues');
@@ -34,6 +33,10 @@ export function IssuesSection({ projectId }: { projectId: number }) {
useFocusEffect(load); useFocusEffect(load);
useEffect(() => {
if (refreshKey) void load();
}, [refreshKey, load]);
return ( return (
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
{canCreate && ( {canCreate && (
@@ -71,16 +74,16 @@ export function IssuesSection({ projectId }: { projectId: number }) {
{item.priority && ( {item.priority && (
<Badge <Badge
label={item.priority} label={item.priority}
color={ISSUE_PRIORITY_COLOR[item.priority] ?? COLORS.muted} color={ISSUE_PRIORITY_COLOR[item.priority] ?? '#666'}
/> />
)} )}
{item.status && ( {item.status && (
<Badge <Badge
label={item.status} label={item.status}
color={ISSUE_STATUS_COLOR[item.status] ?? COLORS.muted} color={ISSUE_STATUS_COLOR[item.status] ?? '#666'}
/> />
)} )}
{item.id < 0 && <Badge label="local" color={COLORS.warn} />} {item.id < 0 && <Badge label="local" color="#8a6d00" />}
</View> </View>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
@@ -94,12 +97,12 @@ export function IssuesSection({ projectId }: { projectId: number }) {
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
toolbar: { padding: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: COLORS.border }, toolbar: { padding: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: '#ddd' }, // border
row: { row: {
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 14, paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
borderColor: COLORS.border, borderColor: '#ddd', // border
}, },
rowActive: { backgroundColor: '#eef5f0' }, rowActive: { backgroundColor: '#eef5f0' },
title: { fontSize: 15, fontWeight: '600' }, title: { fontSize: 15, fontWeight: '600' },
+25
View File
@@ -239,3 +239,28 @@ export async function runSync(projectId: number): Promise<SyncReport> {
return report; return report;
} }
/**
* Versión ligera del sync que solo vacía las colas de salida (PUSH + MEDIA).
* No requiere un proyecto activo ni realiza PULL. Útil para el auto-sync global.
*/
export async function runPushOnly(): Promise<void> {
const report: SyncReport = {
pushed: 0,
applied: 0,
conflicts: 0,
errors: 0,
mediaUploaded: 0,
mediaErrors: 0,
pulled: false,
};
try {
await pushOperations(report);
await pushMedia(report);
await purgeSentOperations();
} catch (e) {
// Silencioso: el usuario verá el estado en la barra de sync si hay problemas
console.warn('Global push-only sync failed:', e);
}
}
+4 -2
View File
@@ -68,9 +68,11 @@ export async function createInspection(input: {
status?: string; status?: string;
result?: string; result?: string;
notes?: string; notes?: string;
uuid?: string;
localId?: number;
}): Promise<string> { }): Promise<string> {
const uuid = newUuid(); const uuid = input.uuid ?? newUuid();
const tempId = nextTempId(); const tempId = input.localId ?? nextTempId();
await insertLocalCreate('inspection', tempId, uuid, { await insertLocalCreate('inspection', tempId, uuid, {
feature_id: input.feature_id, feature_id: input.feature_id,
template_id: input.template_id ?? null, template_id: input.template_id ?? null,
+224 -118
View File
@@ -1,32 +1,13 @@
/** /**
* Mapa de features. Dibuja la geometría GeoJSON del proyecto (puntos, líneas, * Mapa de features usando OpenStreetMap via Leaflet y WebView.
* polígonos) sobre Google Maps y permite seleccionar una feature tocándola. * Elimina la dependencia de la API Key de Google Maps.
* * Añade ubicación del usuario y mapa satelital.
* Nota: las tiles de Google Maps requieren conexión; la geometría sí se dibuja
* sin red. Requiere una API key de Google Maps (ver app.config.js / README).
*/ */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { WebView } from 'react-native-webview';
import * as Location from 'expo-location'; import * as Location from 'expo-location';
import React, { useMemo, useRef } from 'react';
import { Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import MapView, { Marker, Polygon, Polyline, PROVIDER_DEFAULT, PROVIDER_GOOGLE } from 'react-native-maps';
// Android usa Google Maps (requiere GOOGLE_MAPS_API_KEY).
// iOS usa Apple Maps por defecto (sin key, PROVIDER_DEFAULT).
const MAP_PROVIDER = Platform.OS === 'android' ? PROVIDER_GOOGLE : PROVIDER_DEFAULT;
import { Feature } from '../api/types'; import { Feature } from '../api/types';
import { COLORS } from './components';
import { geometryToShapes, LatLng, regionFor } from './geojson';
const STATUS_COLOR: Record<string, string> = {
pending: '#9aa0a6',
in_progress: '#1f6f43',
completed: '#2e7d32',
blocked: '#b00020',
};
function colorFor(status?: string): string {
return (status && STATUS_COLOR[status]) || COLORS.primary;
}
export function FeatureMap({ export function FeatureMap({
features, features,
@@ -37,113 +18,238 @@ export function FeatureMap({
selectedId: number | null; selectedId: number | null;
onSelect: (id: number) => void; onSelect: (id: number) => void;
}) { }) {
const mapRef = useRef<MapView>(null); const webViewRef = useRef<WebView>(null);
const [userLocation, setUserLocation] = useState<Location.LocationObjectCoords | null>(null);
const { shaped, region } = useMemo(() => { // Vigilancia de la ubicación del usuario
const all: LatLng[] = []; useEffect(() => {
const shaped = features.map((f) => { let sub: Location.LocationSubscription | null = null;
const shapes = geometryToShapes(f.geometry);
all.push(...shapes.points, ...shapes.lines.flat(), ...shapes.polygons.flat());
return { feature: f, shapes };
});
return { shaped, region: regionFor(all) };
}, [features]);
const recenter = async () => { (async () => {
const perm = await Location.requestForegroundPermissionsAsync(); const { status } = await Location.requestForegroundPermissionsAsync();
if (!perm.granted) return; if (status !== 'granted') return;
const pos = await Location.getCurrentPositionAsync({});
mapRef.current?.animateToRegion({ const last = await Location.getLastKnownPositionAsync();
latitude: pos.coords.latitude, if (last) setUserLocation(last.coords);
longitude: pos.coords.longitude,
latitudeDelta: 0.01, sub = await Location.watchPositionAsync(
longitudeDelta: 0.01, { accuracy: Location.Accuracy.Balanced, distanceInterval: 5 },
}); (loc) => {
setUserLocation(loc.coords);
// Inyectar posición en el mapa si el webview ya está listo
webViewRef.current?.injectJavaScript(`if(window.updateUserPosition) window.updateUserPosition(${loc.coords.latitude}, ${loc.coords.longitude}); true;`);
}
);
})();
return () => sub?.remove();
}, []);
const centerOnUser = useCallback(() => {
if (userLocation) {
const { latitude, longitude } = userLocation;
webViewRef.current?.injectJavaScript(`if(window.centerOnUser) window.centerOnUser(${latitude}, ${longitude}); true;`);
}
}, [userLocation]);
// Convertimos las features a un objeto GeoJSON simple para Leaflet
const geoData = useMemo(() => {
return {
type: 'FeatureCollection',
features: features.map((f) => {
let geom = f.geometry;
if (typeof geom === 'string') {
try {
geom = JSON.parse(geom);
} catch (e) {
console.warn('Invalid geometry JSON for feature', f.id);
geom = null;
}
}
return {
type: 'Feature',
id: f.id,
geometry: geom,
properties: {
name: f.name,
status: f.status,
selected: f.id === selectedId,
},
};
}).filter(f => f.geometry),
};
}, [features, selectedId]);
const htmlContent = useMemo(() => `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
body { margin: 0; padding: 0; }
#map { height: 100vh; width: 100vw; background: #f0f0f0; }
.user-marker {
width: 16px;
height: 16px;
background: #2196F3;
border: 2px solid white;
border-radius: 50%;
box-shadow: 0 0 10px rgba(33, 150, 243, 0.6);
}
</style>
</head>
<body>
<div id="map"></div>
<script>
const streetLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap'
});
const satelliteLayer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: 'Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EBP, and the GIS User Community'
});
const map = L.map('map', {
zoomControl: false,
layers: [streetLayer]
});
L.control.layers({
"Callejero": streetLayer,
"Satélite": satelliteLayer
}, null, { position: 'topright' }).addTo(map);
const geoData = ${JSON.stringify(geoData)};
const STATUS_COLORS = {
pending: '#9aa0a6',
in_progress: '#1f6f43',
completed: '#2e7d32',
blocked: '#b00020'
};
const geoLayer = L.geoJSON(geoData, {
style: (feature) => ({
color: feature.properties.selected ? '#000' : (STATUS_COLORS[feature.properties.status] || '#1f6f43'),
weight: feature.properties.selected ? 4 : 2,
fillOpacity: 0.4,
fillColor: STATUS_COLORS[feature.properties.status] || '#1f6f43'
}),
pointToLayer: (feature, latlng) => {
return L.circleMarker(latlng, {
radius: 8,
fillColor: STATUS_COLORS[feature.properties.status] || '#1f6f43',
color: feature.properties.selected ? '#000' : '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.8
});
},
onEachFeature: (feature, layer) => {
layer.on('click', () => {
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'select', id: feature.id }));
});
}
}).addTo(map);
// Marcador de usuario
let userMarker = null;
window.updateUserPosition = function(lat, lng) {
if (!userMarker) {
userMarker = L.marker([lat, lng], {
icon: L.divIcon({
className: 'user-marker-wrap',
html: '<div class="user-marker"></div>',
iconSize: [16, 16],
iconAnchor: [8, 8]
})
}).addTo(map);
} else {
userMarker.setLatLng([lat, lng]);
}
};
window.centerOnUser = function(lat, lng) {
window.updateUserPosition(lat, lng);
map.flyTo([lat, lng], 18);
};
if (geoData.features.length > 0) {
try {
const bounds = geoLayer.getBounds();
if (bounds.isValid()) {
map.fitBounds(bounds, { padding: [20, 20] });
} else {
map.setView([40.4167, -3.7037], 13);
}
} catch(e) {
map.setView([40.4167, -3.7037], 13);
}
} else {
map.setView([40.4167, -3.7037], 13); // Madrid default
}
// Posición inicial si ya la tenemos
${userLocation ? `window.updateUserPosition(${userLocation.latitude}, ${userLocation.longitude});` : ''}
</script>
</body>
</html>
`, [geoData, userLocation != null]);
const onMessage = (event: any) => {
try {
const data = JSON.parse(event.nativeEvent.data);
if (data.type === 'select') {
onSelect(data.id);
}
} catch (e) {
console.warn('Error parsing WebView message:', e);
}
}; };
if (!region) {
return (
<View style={styles.empty}>
<Text style={{ color: COLORS.muted }}>Las features no tienen geometría.</Text>
</View>
);
}
return ( return (
<View style={styles.container}> <View style={styles.container}>
<MapView <WebView
ref={mapRef} ref={webViewRef}
style={StyleSheet.absoluteFill} originWhitelist={['*']}
provider={MAP_PROVIDER} source={{ html: htmlContent }}
initialRegion={region} onMessage={onMessage}
showsUserLocation style={styles.map}
> javaScriptEnabled={true}
{shaped.map(({ feature, shapes }) => { domStorageEnabled={true}
const color = colorFor(feature.status); />
const selected = feature.id === selectedId; {userLocation && (
const stroke = selected ? '#000' : color; <TouchableOpacity style={styles.fab} onPress={centerOnUser} activeOpacity={0.8}>
return ( <Text style={styles.fabIcon}>🎯</Text>
<React.Fragment key={feature.id}> </TouchableOpacity>
{shapes.points.map((p, i) => ( )}
<Marker
key={`pt${feature.id}-${i}`}
coordinate={p}
pinColor={color}
title={feature.name}
onPress={() => onSelect(feature.id)}
/>
))}
{shapes.lines.map((line, i) => (
<Polyline
key={`ln${feature.id}-${i}`}
coordinates={line}
strokeColor={stroke}
strokeWidth={selected ? 5 : 3}
tappable
onPress={() => onSelect(feature.id)}
/>
))}
{shapes.polygons.map((poly, i) => (
<Polygon
key={`pg${feature.id}-${i}`}
coordinates={poly}
strokeColor={stroke}
fillColor={`${color}55`}
strokeWidth={selected ? 4 : 2}
tappable
onPress={() => onSelect(feature.id)}
/>
))}
</React.Fragment>
);
})}
</MapView>
<TouchableOpacity style={styles.locBtn} onPress={() => void recenter()}>
<Text style={styles.locIcon}></Text>
</TouchableOpacity>
</View> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1 }, container: { flex: 1, backgroundColor: '#f0f0f0' },
empty: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 }, map: { flex: 1 },
locBtn: { fab: {
position: 'absolute', position: 'absolute',
right: 16, bottom: 24,
bottom: 16, right: 24,
width: 48, width: 56,
height: 48, height: 56,
borderRadius: 24, borderRadius: 28,
backgroundColor: '#fff', backgroundColor: '#fff',
elevation: 6,
shadowColor: '#000',
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.3,
shadowRadius: 4,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
elevation: 4, borderWidth: StyleSheet.hairlineWidth,
shadowColor: '#000', borderColor: '#ddd',
shadowOpacity: 0.2,
shadowRadius: 4,
shadowOffset: { width: 0, height: 2 },
}, },
locIcon: { fontSize: 22, color: COLORS.primary }, fabIcon: { fontSize: 24 },
}); });
+242 -146
View File
@@ -1,21 +1,26 @@
/** /**
* Tira de fotos de un registro. Muestra las ya sincronizadas (tabla `media`) * Tira de fotos de un registro. Muestra las ya sincronizadas (tabla `media`)
* y las locales en cola (`media_outbox`). Permite añadir nuevas desde cámara * y las locales en cola (`media_outbox`). Permite añadir nuevas desde cámara;
* o galería; antes de encolarlas les estampa un pie de página georreferenciado * antes de encolarlas les estampa un pie de página georreferenciado.
* (logo + proyecto + fecha + coordenadas) capturado con react-native-view-shot.
* *
* El pie de página es configurable desde PhotoSettingsScreen (icono ⚙). * Mejoras:
* - Acceso directo a cámara.
* - Layout en cuadrícula (grid).
* - Selección múltiple para borrado masivo.
* - Visor de imagen integrado.
* - Procesado de sello en segundo plano (no bloquea).
*/ */
import * as ImagePicker from 'expo-image-picker';
import * as Location from 'expo-location'; import * as Location from 'expo-location';
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { import {
Alert, Alert,
Image, Image,
Modal,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
Text, Text,
TouchableOpacity, TouchableOpacity,
useWindowDimensions,
View, View,
} from 'react-native'; } from 'react-native';
import { captureRef } from 'react-native-view-shot'; import { captureRef } from 'react-native-view-shot';
@@ -23,21 +28,22 @@ import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Media, MediaParentEntity } from '../api/types'; import { Media, MediaParentEntity } from '../api/types';
import { absoluteUrl } from '../config'; import { absoluteUrl } from '../config';
import { enqueueMedia, getMediaOutboxFor, MediaOutboxRow } from '../db/outbox'; import { deleteMediaOutbox, enqueueMedia, getMediaOutboxFor, MediaOutboxRow, updateMediaLocalUri } from '../db/outbox';
import { getActiveProjectId, getMediaFor, getProject } from '../db/repositories'; import { getActiveProjectId, getMediaFor, getProject } from '../db/repositories';
import { RootStackParamList } from '../navigation/types'; import { RootStackParamList } from '../navigation/types';
import { import {
FooterConfig, FooterConfig,
loadFooterConfig, loadFooterConfig,
RESOLUTION_WIDTHS,
resolveFieldValue, resolveFieldValue,
StampMeta, StampMeta,
} from '../photo/footerConfig'; } from '../photo/footerConfig';
import { FooterOverlay } from '../photo/FooterOverlay'; import { FooterOverlay } from '../photo/FooterOverlay';
import { COLORS } from './components';
// ─── tipos internos ──────────────────────────────────────────────────────── // ─── tipos internos ────────────────────────────────────────────────────────
interface ComposingTask { interface ComposingTask {
uuid: string; // Para saber qué registro actualizar al terminar
uri: string; uri: string;
renderW: number; renderW: number;
renderH: number; renderH: number;
@@ -47,17 +53,13 @@ interface ComposingTask {
type Nav = NativeStackNavigationProp<RootStackParamList>; type Nav = NativeStackNavigationProp<RootStackParamList>;
const MAX_STAMP_W = 2048;
// ─── helpers ─────────────────────────────────────────────────────────────── // ─── helpers ───────────────────────────────────────────────────────────────
async function getCoords(): Promise<{ latitude: number; longitude: number } | null> { async function getCoords(): Promise<{ latitude: number; longitude: number } | null> {
try { try {
const { granted } = await Location.requestForegroundPermissionsAsync(); const { granted } = await Location.requestForegroundPermissionsAsync();
if (!granted) return null; if (!granted) return null;
const loc = await Location.getCurrentPositionAsync({ const loc = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced });
accuracy: Location.Accuracy.Balanced,
});
return loc.coords; return loc.coords;
} catch { } catch {
return null; return null;
@@ -82,13 +84,20 @@ export function MediaStrip({
canUpload: boolean; canUpload: boolean;
}) { }) {
const navigation = useNavigation<Nav>(); const navigation = useNavigation<Nav>();
const { width: winW } = useWindowDimensions();
const [synced, setSynced] = useState<Media[]>([]); const [synced, setSynced] = useState<Media[]>([]);
const [pending, setPending] = useState<MediaOutboxRow[]>([]); const [pending, setPending] = useState<MediaOutboxRow[]>([]);
const [composing, setComposing] = useState<ComposingTask | null>(null); const [composing, setComposing] = useState<ComposingTask | null>(null);
// Estados para selección y visor
const [selectedUuids, setSelectedUuids] = useState<Set<string>>(new Set());
const [viewerUri, setViewerUri] = useState<string | null>(null);
const captureViewRef = useRef<View>(null); const captureViewRef = useRef<View>(null);
const resolveRef = useRef<((uri: string) => void) | null>(null);
const rejectRef = useRef<((e: unknown) => void) | null>(null); // Cola de tareas de procesado para no perder ninguna
const queueRef = useRef<ComposingTask[]>([]);
// ── cargar datos ────────────────────────────────────────────────────────── // ── cargar datos ──────────────────────────────────────────────────────────
@@ -103,43 +112,51 @@ export function MediaStrip({
useEffect(() => { void refresh(); }, [refresh]); useEffect(() => { void refresh(); }, [refresh]);
// ── compositing (ViewShot) ──────────────────────────────────────────────── // ── compositing (Procesado en 2º plano) ───────────────────────────────────
/** Inicia el siguiente procesado de la cola si no hay uno en curso. */
const processNext = useCallback(() => {
if (composing || queueRef.current.length === 0) return;
const next = queueRef.current.shift();
if (next) setComposing(next);
}, [composing]);
useEffect(() => {
processNext();
}, [composing, processNext]);
/** Llamado por Image.onLoad cuando la imagen del composer ya está dibujada. */
const onCaptureImageLoaded = useCallback(() => { const onCaptureImageLoaded = useCallback(() => {
// Un frame para que el layout nativo finalice antes de capturar. if (!composing) return;
setTimeout(async () => { setTimeout(async () => {
if (!captureViewRef.current) { if (!captureViewRef.current) {
rejectRef.current?.(new Error('captureRef is null'));
setComposing(null); setComposing(null);
return; return;
} }
try { try {
const uri = await captureRef(captureViewRef, { format: 'jpg', quality: 0.85 }); const stampedUri = await captureRef(captureViewRef, {
resolveRef.current?.(uri); format: 'jpg',
quality: composing.config.quality
});
// Actualizar el registro local con la imagen ya sellada
await updateMediaLocalUri(composing.uuid, stampedUri);
await refresh();
} catch (e) { } catch (e) {
rejectRef.current?.(e); console.error('Stamping failed:', e);
} finally { } finally {
setComposing(null); setComposing(null); // Esto disparará el siguiente en la cola vía useEffect
resolveRef.current = null;
rejectRef.current = null;
} }
}, 80); }, 100);
}, []); }, [composing, refresh]);
/** /** Prepara una tarea de estampado y la mete en la cola. */
* Estampa el pie de página sobre `rawUri`. async function scheduleStamp(uuid: string, rawUri: string, origW: number, origH: number) {
* Si el footer está desactivado o vacío devuelve el URI original intacto.
*/
async function stamp(rawUri: string, origW: number, origH: number): Promise<string> {
const [config, projectId] = await Promise.all([ const [config, projectId] = await Promise.all([
loadFooterConfig(), loadFooterConfig(),
getActiveProjectId(), getActiveProjectId(),
]); ]);
const hasContent = config.enabled && if (!config.enabled) return;
(config.logoUri != null || config.fields.some((f) => f.enabled));
if (!hasContent) return rawUri;
let projectName: string | null = null; let projectName: string | null = null;
if (projectId != null) { if (projectId != null) {
@@ -157,127 +174,172 @@ export function MediaStrip({
coordinates: coordsRaw ? formatCoords(coordsRaw.latitude, coordsRaw.longitude) : null, coordinates: coordsRaw ? formatCoords(coordsRaw.latitude, coordsRaw.longitude) : null,
}; };
const scale = Math.min(1, MAX_STAMP_W / origW); const targetW = RESOLUTION_WIDTHS[config.resolution] || 1920;
const renderW = Math.round(origW * scale); const scale = Math.min(1, targetW / origW);
const renderH = Math.round(origH * scale);
return new Promise<string>((resolve, reject) => { queueRef.current.push({
resolveRef.current = resolve; uuid,
rejectRef.current = reject; uri: rawUri,
setComposing({ uri: rawUri, renderW, renderH, meta, config }); renderW: Math.round(origW * scale),
renderH: Math.round(origH * scale),
meta,
config,
}); });
processNext();
} }
// ── captura de foto ─────────────────────────────────────────────────────── // ── acciones ──────────────────────────────────────────────────────────────
const addPhoto = useCallback( const handleCapture = async (uri: string, width: number, height: number) => {
async (source: 'camera' | 'library') => { // 1. Guardar la imagen original inmediatamente para respuesta instantánea
const perm = const uuid = await enqueueMedia({
source === 'camera' parentEntity,
? await ImagePicker.requestCameraPermissionsAsync() parentId,
: await ImagePicker.requestMediaLibraryPermissionsAsync(); localUri: uri,
if (!perm.granted) { mimeType: 'image/jpeg',
Alert.alert('Permiso necesario', 'Concede el permiso para añadir fotos.'); category: 'image',
return; });
}
const result = await refresh(); // Mostrar en la galería ya
source === 'camera'
? await ImagePicker.launchCameraAsync({ quality: 0.85 })
: await ImagePicker.launchImageLibraryAsync({ quality: 0.85, mediaTypes: 'images' });
if (result.canceled || !result.assets?.length) return;
const asset = result.assets[0]; // 2. Programar el estampado en segundo plano
void scheduleStamp(uuid, uri, width, height);
let finalUri: string; };
try {
finalUri = await stamp(asset.uri, asset.width ?? 1920, asset.height ?? 1080);
} catch {
// Si el stamping falla, usar la foto original.
finalUri = asset.uri;
}
await enqueueMedia({
parentEntity,
parentId,
localUri: finalUri,
fileName: asset.fileName ?? undefined,
mimeType: asset.mimeType ?? 'image/jpeg',
category: 'image',
});
await refresh();
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[parentEntity, parentId, refresh],
);
const onAdd = useCallback(() => { const onAdd = useCallback(() => {
Alert.alert('Añadir foto', undefined, [ navigation.navigate('Camera', { onCapture: handleCapture });
{ text: 'Cámara', onPress: () => void addPhoto('camera') }, }, [navigation, handleCapture]);
{ text: 'Galería', onPress: () => void addPhoto('library') },
const toggleSelect = (uuid: string) => {
setSelectedUuids((prev) => {
const next = new Set(prev);
if (next.has(uuid)) next.delete(uuid);
else next.add(uuid);
return next;
});
};
const deleteSelected = async () => {
const count = selectedUuids.size;
if (count === 0) return;
Alert.alert('Eliminar fotos', `¿Deseas eliminar las ${count} fotos seleccionadas?`, [
{ text: 'Cancelar', style: 'cancel' }, { text: 'Cancelar', style: 'cancel' },
{
text: 'Eliminar',
style: 'destructive',
onPress: async () => {
for (const uuid of selectedUuids) {
await deleteMediaOutbox(uuid);
}
setSelectedUuids(new Set());
await refresh();
},
},
]); ]);
}, [addPhoto]); };
// ─── render ─────────────────────────────────────────────────────────────── // ─── render ───────────────────────────────────────────────────────────────
const isSelectionMode = selectedUuids.size > 0;
const thumbSize = (winW - 32 - 16) / 3;
if (synced.length === 0 && pending.length === 0 && !canUpload) return null; if (synced.length === 0 && pending.length === 0 && !canUpload) return null;
return ( return (
<View style={styles.wrapper}> <View style={styles.wrapper}>
{/* Cabecera con icono de configuración */} <View style={styles.header}>
{canUpload && ( <Text style={styles.headerLabel}>
<View style={styles.header}> {isSelectionMode ? `${selectedUuids.size} seleccionadas` : `Fotos (${synced.length + pending.length})`}
<Text style={styles.headerLabel}>Fotos</Text> </Text>
<TouchableOpacity onPress={() => navigation.navigate('PhotoSettings')}> <View style={styles.headerActions}>
<Text style={styles.gear}></Text> {isSelectionMode ? (
</TouchableOpacity> <TouchableOpacity onPress={deleteSelected} style={styles.deleteAction}>
<Text style={styles.deleteActionTxt}>Borrar</Text>
</TouchableOpacity>
) : (
<TouchableOpacity onPress={() => navigation.navigate('PhotoSettings')}>
<Text style={styles.gear}></Text>
</TouchableOpacity>
)}
</View> </View>
)} </View>
{/* Tira de miniaturas */} <View style={styles.grid}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.row}
>
{canUpload && ( {canUpload && (
<TouchableOpacity style={styles.addBtn} onPress={onAdd}> <TouchableOpacity
style={[styles.addBtn, { width: thumbSize, height: thumbSize }]}
onPress={onAdd}
disabled={isSelectionMode}
>
<Text style={styles.addPlus}></Text> <Text style={styles.addPlus}></Text>
<Text style={styles.addText}>Foto</Text> <Text style={styles.addText}>Añadir</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
{pending.map((m) => (
<View key={m.uuid} style={styles.thumbWrap}>
<Image source={{ uri: m.local_uri }} style={styles.thumb} />
<View
style={[
styles.tag,
{ backgroundColor: m.status === 'error' ? COLORS.danger : COLORS.warn },
]}
>
<Text style={styles.tagText}>
{m.status === 'error' ? 'error' : 'en cola'}
</Text>
</View>
</View>
))}
{synced.map((m) => (
<View key={`s${m.id}`} style={styles.thumbWrap}>
<Image source={{ uri: absoluteUrl(m.url) }} style={styles.thumb} />
</View>
))}
</ScrollView>
{/* Vista fuera de pantalla para compositar el pie de página */} {pending.map((m) => {
{composing ? ( const isSelected = selectedUuids.has(m.uuid);
const isProcessing = composing?.uuid === m.uuid || queueRef.current.some(q => q.uuid === m.uuid);
return (
<TouchableOpacity
key={m.uuid}
style={[styles.thumbWrap, { width: thumbSize, height: thumbSize }, isSelected && styles.selectedThumb]}
onPress={() => isSelectionMode ? toggleSelect(m.uuid) : setViewerUri(m.local_uri)}
onLongPress={() => toggleSelect(m.uuid)}
activeOpacity={0.8}
>
<Image source={{ uri: m.local_uri }} style={styles.thumb} />
{isProcessing && (
<View style={styles.processingOverlay}>
<Text style={styles.processingTxt}>Sellando</Text>
</View>
)}
{isSelected && (
<View style={styles.checkOverlay}>
<Text style={styles.checkIcon}></Text>
</View>
)}
{m.status === 'error' && (
<View style={[styles.tag, { backgroundColor: '#b00020' }]}>
<Text style={styles.tagText}>error</Text>
</View>
)}
</TouchableOpacity>
);
})}
{synced.map((m) => (
<TouchableOpacity
key={`s${m.id}`}
style={[styles.thumbWrap, { width: thumbSize, height: thumbSize }]}
onPress={() => setViewerUri(absoluteUrl(m.url))}
>
<Image source={{ uri: absoluteUrl(m.url) }} style={styles.thumb} />
</TouchableOpacity>
))}
</View>
<Modal visible={!!viewerUri} transparent={false} animationType="fade">
<View style={styles.viewerContainer}>
<Image source={{ uri: viewerUri ?? undefined }} style={styles.viewerImg} resizeMode="contain" />
<TouchableOpacity style={styles.viewerClose} onPress={() => setViewerUri(null)}>
<Text style={styles.viewerCloseTxt}>Cerrar</Text>
</TouchableOpacity>
</View>
</Modal>
{/* COMPOSER OCULTO: Solo renderiza si hay una tarea en la cola */}
{composing && (
<View <View
ref={captureViewRef} ref={captureViewRef}
collapsable={false} collapsable={false}
style={{ style={{
position: 'absolute', position: 'absolute',
top: 0, top: 0,
left: -(composing.renderW + 100), left: -(composing.renderW + 500),
width: composing.renderW, width: composing.renderW,
height: composing.renderH, height: composing.renderH,
overflow: 'hidden', overflow: 'hidden',
@@ -285,55 +347,75 @@ export function MediaStrip({
> >
<Image <Image
source={{ uri: composing.uri }} source={{ uri: composing.uri }}
style={StyleSheet.absoluteFill} style={{ width: composing.renderW, height: composing.renderH }}
resizeMode="stretch" resizeMode="stretch"
onLoad={onCaptureImageLoaded} onLoad={onCaptureImageLoaded}
/> />
<FooterOverlay <FooterOverlay config={composing.config} meta={composing.meta} imageWidth={composing.renderW} />
config={composing.config}
meta={composing.meta}
imageWidth={composing.renderW}
/>
</View> </View>
) : null} )}
</View> </View>
); );
} }
// ─── estilos ──────────────────────────────────────────────────────────────
const styles = StyleSheet.create({ const styles = StyleSheet.create({
wrapper: { marginVertical: 8 }, wrapper: { marginVertical: 8 },
header: { header: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 2, paddingHorizontal: 4,
marginBottom: 6, marginBottom: 10,
},
headerLabel: { fontSize: 14, fontWeight: '700', color: '#666' }, // muted
headerActions: { flexDirection: 'row', alignItems: 'center' },
gear: { fontSize: 18, color: '#666', paddingHorizontal: 8 }, // muted
deleteAction: { backgroundColor: '#b00020', paddingHorizontal: 12, paddingVertical: 4, borderRadius: 6 }, // danger
deleteActionTxt: { color: '#fff', fontSize: 12, fontWeight: 'bold' },
grid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
}, },
headerLabel: { fontSize: 13, fontWeight: '600', color: COLORS.muted },
gear: { fontSize: 18, color: COLORS.muted, paddingHorizontal: 4 },
row: { gap: 8, paddingRight: 8 },
addBtn: { addBtn: {
width: 72,
height: 72,
borderRadius: 8, borderRadius: 8,
borderWidth: 1, borderWidth: 1,
borderColor: COLORS.primary, borderColor: '#1f6f43', // primary
borderStyle: 'dashed', borderStyle: 'dashed',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
backgroundColor: '#fff',
}, },
addPlus: { color: COLORS.primary, fontSize: 22, fontWeight: '700' }, addPlus: { color: '#1f6f43', fontSize: 24, fontWeight: '700' }, // primary
addText: { color: COLORS.primary, fontSize: 11 }, addText: { color: '#1f6f43', fontSize: 11, marginTop: 2 }, // primary
thumbWrap: { thumbWrap: {
width: 72,
height: 72,
borderRadius: 8, borderRadius: 8,
overflow: 'hidden', overflow: 'hidden',
backgroundColor: COLORS.bg, backgroundColor: '#f4f4f4', // bg
borderWidth: 2,
borderColor: 'transparent',
}, },
selectedThumb: { borderColor: '#1f6f43' }, // primary
thumb: { width: '100%', height: '100%' }, thumb: { width: '100%', height: '100%' },
checkOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(31, 111, 67, 0.4)',
justifyContent: 'center',
alignItems: 'center',
},
checkIcon: { color: '#fff', fontSize: 32, fontWeight: 'bold' },
processingOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
alignItems: 'center',
},
processingTxt: { color: '#fff', fontSize: 10, fontWeight: 'bold' },
tag: { tag: {
position: 'absolute', position: 'absolute',
bottom: 0, bottom: 0,
@@ -343,4 +425,18 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
}, },
tagText: { color: '#fff', fontSize: 9, fontWeight: '700' }, tagText: { color: '#fff', fontSize: 9, fontWeight: '700' },
/* Visor */
viewerContainer: { flex: 1, backgroundColor: '#000', justifyContent: 'center' },
viewerImg: { width: '100%', height: '80%' },
viewerClose: {
position: 'absolute',
bottom: 40,
alignSelf: 'center',
paddingHorizontal: 30,
paddingVertical: 12,
backgroundColor: 'rgba(255,255,255,0.2)',
borderRadius: 25,
},
viewerCloseTxt: { color: '#fff', fontWeight: 'bold' },
}); });
+8
View File
@@ -0,0 +1,8 @@
export const COLORS = {
primary: '#1f6f43',
warn: '#8a6d00',
danger: '#b00020',
muted: '#666',
border: '#ddd',
bg: '#f4f4f4',
};
+9 -9
View File
@@ -60,8 +60,8 @@ export function PrimaryButton({
variant?: 'primary' | 'danger' | 'ghost'; variant?: 'primary' | 'danger' | 'ghost';
}) { }) {
const bg = const bg =
variant === 'danger' ? COLORS.danger : variant === 'ghost' ? 'transparent' : COLORS.primary; variant === 'danger' ? '#b00020' : variant === 'ghost' ? 'transparent' : '#1f6f43';
const fg = variant === 'ghost' ? COLORS.primary : '#fff'; const fg = variant === 'ghost' ? '#1f6f43' : '#fff';
return ( return (
<TouchableOpacity <TouchableOpacity
style={[ style={[
@@ -155,21 +155,21 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
buttonGhost: { borderWidth: 1, borderColor: COLORS.primary }, buttonGhost: { borderWidth: 1, borderColor: '#1f6f43' },
buttonDisabled: { opacity: 0.5 }, buttonDisabled: { opacity: 0.5 },
buttonText: { fontSize: 15, fontWeight: '700' }, buttonText: { fontSize: 15, fontWeight: '700' },
card: { card: {
backgroundColor: COLORS.bg, backgroundColor: '#f4f4f4',
borderRadius: 10, borderRadius: 10,
padding: 12, padding: 12,
}, },
sectionTitle: { fontSize: 16, fontWeight: '700', marginTop: 8, marginBottom: 4 }, sectionTitle: { fontSize: 16, fontWeight: '700', marginTop: 8, marginBottom: 4 },
empty: { color: '#888', textAlign: 'center', marginTop: 24 }, empty: { color: '#888', textAlign: 'center', marginTop: 24 },
field: { marginBottom: 12 }, field: { marginBottom: 12 },
fieldLabel: { fontSize: 13, color: COLORS.muted, marginBottom: 4, fontWeight: '600' }, fieldLabel: { fontSize: 13, color: '#666', marginBottom: 4, fontWeight: '600' },
input: { input: {
borderWidth: 1, borderWidth: 1,
borderColor: COLORS.border, borderColor: '#ddd',
borderRadius: 8, borderRadius: 8,
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 10,
@@ -182,10 +182,10 @@ const styles = StyleSheet.create({
paddingVertical: 6, paddingVertical: 6,
borderRadius: 16, borderRadius: 16,
borderWidth: 1, borderWidth: 1,
borderColor: COLORS.border, borderColor: '#ddd',
backgroundColor: '#fff', backgroundColor: '#fff',
}, },
chipActive: { backgroundColor: COLORS.primary, borderColor: COLORS.primary }, chipActive: { backgroundColor: '#1f6f43', borderColor: '#1f6f43' },
chipText: { fontSize: 13, color: COLORS.muted }, chipText: { fontSize: 13, color: '#666' },
chipTextActive: { color: '#fff', fontWeight: '700' }, chipTextActive: { color: '#fff', fontWeight: '700' },
}); });