Compare commits

...
11 Commits
Author SHA1 Message Date
javier 80e4657b15 Mejoras en el mapa (satélite, ubicación), configuración de relación de aspecto en cámara y correcciones de estabilidad 2026-07-31 12:52:36 +02:00
javier 603a03946b Adjust dependencies to stable versions and clean android folder 2026-07-29 23:02:56 +02:00
javier 038cbc674c Implement system bars management and Edge-to-Edge layout 2026-07-29 14:02:35 +02:00
javier a12ec6ae2f Finalize background photo processing and orientation fixes 2026-07-29 12:29:01 +02:00
javier f2b359e515 Final fixes for photo overlay position and rotation 2026-07-29 09:39:12 +02:00
javier a9ba966176 Fix photo overlay positioning and rotation detection 2026-07-29 00:35:45 +02:00
javier 6d11434061 Implement custom camera with real-time overlay and resolution settings 2026-07-28 23:55:24 +02:00
javier c6240784ff UI improvements and prepare for OSM migration 2026-07-28 20:05:12 +02:00
javier 6ac78ea6a1 Environment fix for Android and UI preparations 2026-07-28 19:42:11 +02:00
javierandClaude Opus 4.8 31de1c1526 fix(deps): resincronizar package-lock.json con npm 10 (build EAS)
npm 11 (local) dedupe y omitía un typescript@5.9.3 anidado que npm 10.9.4
(el de EAS Build) exige, provocando `npm ci` EUSAGE "out of sync" y el fallo
en la fase Install dependencies. Regenerado con npm@10.9.4 para paridad.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 11:34:55 +02:00
javierandClaude Opus 4.8 a59ed64975 chore(eas): enlazar proyecto EAS (projectId)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 11:10:40 +02:00
90 changed files with 11732 additions and 4590 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/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
+10 -1
View File
@@ -1,5 +1,7 @@
import { StatusBar } from 'expo-status-bar';
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 { SessionProvider } from './src/auth/session';
import { getDb } from './src/db/database';
@@ -9,13 +11,20 @@ export default function App() {
// Abre y migra la BD local al arrancar.
useEffect(() => {
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 (
<SafeAreaProvider>
<SessionProvider>
<RootNavigator />
<StatusBar style="light" />
<StatusBar style="auto" />
</SessionProvider>
</SafeAreaProvider>
);
-3
View File
@@ -12,8 +12,5 @@ local.properties
*.hprof
.cxx/
# generated inline modules
app/src/main/java/inline/
# Bundle artifacts
*.jsbundle
+45 -26
View File
@@ -4,6 +4,27 @@ apply plugin: "com.facebook.react"
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.
* 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 {
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
hermesCommand = new File(["node", "--print", "require.resolve('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()
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
// Use Expo CLI to bundle the app, this ensures the Metro config
// works correctly with Expo projects.
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
bundleCommand = "export:embed"
/* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '../..'
// root = file("../../")
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
// reactNativeDir = file("../../node_modules/react-native")
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
// codegenDir = file("../../node_modules/@react-native/codegen")
// The root of your project, i.e. where "package.json" lives. Default is '..'
// root = file("../")
// The folder where the react-native NPM package is. Default is ../node_modules/react-native
// reactNativeDir = file("../node_modules/react-native")
// The folder where the react-native Codegen package is. Default is ../node_modules/@react-native/codegen
// codegenDir = file("../node_modules/@react-native/codegen")
/* Variants */
// The list of variants to that are debuggable. For those we're going to
@@ -59,14 +79,16 @@ react {
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
// hermesFlags = ["-O", "-output-source-map"]
if (rnVersion >= versionToNumber(0, 75, 0)) {
/* 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)
@@ -79,7 +101,7 @@ def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBu
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
def jscFlavor = 'org.webkit:android-jsc:+'
android {
ndkVersion rootProject.ext.ndkVersion
@@ -94,8 +116,6 @@ android {
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0.0"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
}
signingConfigs {
debug {
@@ -113,23 +133,17 @@ android {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
shrinkResources enableShrinkResources.toBoolean()
minifyEnabled enableMinifyInReleaseBuilds
shrinkResources (findProperty('android.enableShrinkResourcesInReleaseBuilds')?.toBoolean() ?: false)
minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
crunchPngs enablePngCrunchInRelease.toBoolean()
crunchPngs (findProperty('android.enablePngCrunchInReleaseBuilds')?.toBoolean() ?: true)
}
}
packagingOptions {
jniLibs {
def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
useLegacyPackaging enableLegacyPackaging.toBoolean()
useLegacyPackaging (findProperty('expo.useLegacyPackaging')?.toBoolean() ?: false)
}
}
androidResources {
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
// Apply static values from `gradle.properties` to the `android.packagingOptions`
@@ -162,15 +176,15 @@ dependencies {
if (isGifEnabled) {
// 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) {
// For webp support
implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}")
implementation("com.facebook.fresco:webpsupport:${reactAndroidLibs.versions.fresco.get()}")
if (isWebpAnimatedEnabled) {
// 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
}
}
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.CAMERA"/>
<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.SYSTEM_ALERT_WINDOW"/>
<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>
<intent>
<action android:name="android.intent.action.VIEW"/>
@@ -15,16 +15,22 @@
<data android:scheme="https"/>
</intent>
</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.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_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>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</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 android:name="com.facebook.react.devsupport.DevSettingsActivity" android:exported="false"/>
</application>
</manifest>
@@ -5,36 +5,46 @@ import android.content.res.Configuration
import com.facebook.react.PackageList
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.ReactHost
import com.facebook.react.common.ReleaseLevel
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.soloader.SoLoader
import expo.modules.ApplicationLifecycleDispatcher
import expo.modules.ExpoReactHostFactory
import expo.modules.ReactNativeHostWrapper
class MainApplication : Application(), ReactApplication {
override val reactHost: ReactHost by lazy {
ExpoReactHostFactory.getDefaultReactHost(
context = applicationContext,
packageList =
PackageList(this).packages.apply {
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
this,
object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
// packages.add(new 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() {
super.onCreate()
DefaultNewArchitectureEntryPoint.releaseLevel = try {
ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
} catch (e: IllegalArgumentException) {
ReleaseLevel.STABLE
SoLoader.init(this, false)
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// If you opted-in for the New Architecture, we load the native entry point for this app.
load()
}
loadReactNative(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">
<item android:drawable="@color/splashscreen_background"/>
<item>
<bitmap android:gravity="center" android:src="@drawable/splashscreen_logo"/>
</item>
</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>
<color name="splashscreen_background">#FFFFFF</color>
<color name="iconBackground">#E6F4FE</color>
<color name="colorPrimary">#023c69</color>
<color name="colorPrimaryDark">#ffffff</color>
<color name="splashscreen_background">#ffffff</color>
</resources>
+10 -4
View File
@@ -1,11 +1,17 @@
<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="colorPrimary">@color/colorPrimary</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:statusBarColor">#ffffff</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 name="Theme.App.SplashScreen" parent="AppTheme">
<item name="android:windowBackground">@drawable/splashscreen_logo</item>
<item name="android:windowBackground">@drawable/splashscreen</item>
</style>
</resources>
+24 -3
View File
@@ -1,6 +1,15 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext {
buildToolsVersion = findProperty('android.buildToolsVersion') ?: '34.0.0'
minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '23')
compileSdkVersion = Integer.parseInt(findProperty('android.compileSdkVersion') ?: '34')
targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '34')
kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.23'
ndkVersion = "26.1.10909125"
}
repositories {
google()
mavenCentral()
@@ -12,13 +21,25 @@ buildscript {
}
}
apply plugin: "com.facebook.react.rootproject"
allprojects {
repositories {
maven {
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
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'))
}
google()
mavenCentral()
maven { url 'https://www.jitpack.io' }
}
}
apply plugin: "expo-root-project"
apply plugin: "com.facebook.react.rootproject"
// @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.
# 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
org.gradle.parallel=true
# org.gradle.parallel=true
# 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
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
# Enable AAPT2 PNG crunching
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
# to write custom TurboModules/Fabric components OR use libraries that
# are providing them.
newArchEnabled=true
newArchEnabled=false
# Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead.
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)
expo.gif.enabled=true
# 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.
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
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
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
+8 -7
View File
@@ -1,7 +1,7 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (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
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
@@ -57,7 +55,7 @@
# Darwin, MinGW, and NonStop.
#
# (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.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -86,7 +84,7 @@ done
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# 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.
MAX_FD=maximum
@@ -114,6 +112,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# 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
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -203,14 +203,15 @@ fi
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# 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.
# * 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.
set -- \
"-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.
+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 Copyright 2015 the original author or authors.
@rem
@@ -18,8 +13,6 @@
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@@ -75,10 +68,11 @@ goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@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
@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.
}
}
+50 -23
View File
@@ -1,39 +1,66 @@
pluginManagement {
def reactNativeGradlePlugin = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
def version = providers.exec {
commandLine("node", "-e", "console.log(require('react-native/package.json').version);")
}.standardOutput.asText.get().trim()
).getParentFile().absolutePath
includeBuild(reactNativeGradlePlugin)
def (_, reactNativeMinor, reactNativePatch) = version.split("-")[0].tokenize('.').collect { it.toInteger() }
def expoPluginsPath = new File(
providers.exec {
workingDir(rootDir)
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)
includeBuild(new File(["node", "--print", "require.resolve('@react-native/gradle-plugin/package.json')"].execute(null, rootDir).text.trim()).getParentFile().toString())
if(reactNativeMinor == 74 && reactNativePatch <= 3){
includeBuild("react-settings-plugin")
}
}
plugins {
id("com.facebook.react.settings")
id("expo-autolinking-settings")
plugins { id("com.facebook.react.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
}
if (getRNMinorVersion() >= 75) {
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
ex.autolinkLibrariesFromCommand()
if (System.getenv('EXPO_UNSTABLE_CORE_AUTOLINKING') == '1') {
println('\u001B[32mUsing expo-modules-autolinking as core autolinking source\u001B[0m')
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.rnConfigCommand)
ex.autolinkLibrariesFromCommand()
}
}
}
expoAutolinking.useExpoModules()
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'
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())
+7 -2
View File
@@ -32,7 +32,6 @@
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-sqlite",
"expo-secure-store",
"expo-build-properties",
[
@@ -48,6 +47,12 @@
"locationWhenInUsePermission": "Avante usa tu ubicación para situar avances e incidencias en el mapa."
}
]
]
],
"extra": {
"eas": {
"projectId": "0ae52c71-582b-49a2-8e6a-2d9abec314f7"
}
},
"owner": "jbrana"
}
}
+9906 -3802
View File
File diff suppressed because it is too large Load Diff
+25 -21
View File
@@ -3,29 +3,33 @@
"version": "1.0.0",
"main": "index.ts",
"dependencies": {
"@react-native-community/netinfo": "12.0.1",
"@react-navigation/native": "^7.3.3",
"@react-navigation/native-stack": "^7.17.5",
"expo": "~56.0.12",
"expo-build-properties": "~56.0.19",
"expo-crypto": "~56.0.4",
"expo-file-system": "~56.0.8",
"expo-image-picker": "~56.0.18",
"expo-location": "~56.0.18",
"expo-secure-store": "~56.0.4",
"expo-sqlite": "~56.0.5",
"expo-status-bar": "~56.0.4",
"react": "19.2.3",
"react-native": "0.85.3",
"react-native-maps": "1.27.2",
"react-native-view-shot": "^4.0.0",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2"
"@react-native-community/netinfo": "11.3.1",
"@react-navigation/native": "^6.1.17",
"@react-navigation/native-stack": "^6.9.26",
"expo": "~51.0.28",
"expo-build-properties": "~0.12.5",
"expo-camera": "~15.0.16",
"expo-crypto": "~13.0.2",
"expo-file-system": "~17.0.1",
"expo-image-picker": "~15.1.0",
"expo-location": "~17.0.1",
"expo-navigation-bar": "~3.0.7",
"expo-screen-orientation": "~7.0.1",
"expo-secure-store": "~13.0.2",
"expo-sqlite": "~14.0.6",
"expo-status-bar": "~1.12.1",
"react": "18.2.0",
"react-native": "0.74.5",
"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": {
"@types/react": "~19.2.2",
"eas-cli": "^20.2.0",
"typescript": "~6.0.3"
"@types/react": "~18.2.79",
"eas-cli": "^12.6.2",
"typescript": "~5.3.3"
},
"scripts": {
"start": "expo start",
+1
View File
@@ -153,6 +153,7 @@ export interface Template {
export type MediaParentEntity =
| 'feature'
| 'inspection'
| 'issue'
| 'issue_task'
| 'issue_comment'
+11
View File
@@ -265,3 +265,14 @@ export async function markMediaError(uuid: string, error: string): Promise<void>
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);
}
+24 -4
View File
@@ -12,13 +12,21 @@ import { InspectionFormScreen } from '../screens/InspectionFormScreen';
import { IssueCreateScreen } from '../screens/IssueCreateScreen';
import { OutboxScreen } from '../screens/OutboxScreen';
import { PhotoSettingsScreen } from '../screens/PhotoSettingsScreen';
import { SettingsScreen } from '../screens/SettingsScreen';
import { CameraScreen } from '../screens/CameraScreen';
import { RootStackParamList } from './types';
import { useAutoSync } from '../sync/useAutoSync';
import { runPushOnly } from '../sync/engine';
const Stack = createNativeStackNavigator<RootStackParamList>();
export function RootNavigator() {
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) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
@@ -27,12 +35,13 @@ export function RootNavigator() {
);
}
if (!token) {
return <LoginScreen />;
}
return (
<NavigationContainer>
{!token ? (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="Login" component={LoginScreen} />
</Stack.Navigator>
) : (
<Stack.Navigator>
<Stack.Screen
name="Projects"
@@ -74,7 +83,18 @@ export function RootNavigator() {
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>
);
}
+3
View File
@@ -1,4 +1,5 @@
export type RootStackParamList = {
Login: undefined;
Projects: undefined;
ProjectDetail: { projectId: number; name: string };
IssueDetail: { issueId: number; title: string };
@@ -7,4 +8,6 @@ export type RootStackParamList = {
IssueCreate: { projectId: number; featureId?: number };
Outbox: undefined;
PhotoSettings: undefined;
Settings: undefined;
Camera: { onCapture: (uri: string, width: number, height: number) => void };
};
+33 -31
View File
@@ -1,50 +1,55 @@
/**
* Pie de página superpuesto sobre la foto antes de capturarla con ViewShot.
* Se posiciona en la parte inferior de su contenedor con position:absolute.
* Todos los tamaños son proporcionales al ancho de la imagen para que el pie
* sea legible independientemente de la resolución del sensor.
* Pie de página superpuesto sobre la foto.
* Se posiciona en una de las cuatro esquinas según `config.overlayPosition`.
* El diseño es compacto (recuadro con fondo semitransparente).
*/
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';
interface Props {
config: FooterConfig;
meta: StampMeta;
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;
const enabled = config.fields.filter((f) => f.enabled);
if (enabled.length === 0 && !config.logoUri) return null;
const fontSize = Math.max(12, Math.round(imageWidth * 0.016));
const logoDim = Math.max(50, Math.round(imageWidth * 0.09));
const pad = Math.max(8, Math.round(imageWidth * 0.012));
const lineH = Math.round(fontSize * 1.4);
const fontSize = Math.max(10, Math.round(imageWidth * 0.022));
const logoDim = Math.max(40, Math.round(imageWidth * 0.10));
const pad = Math.max(8, Math.round(imageWidth * 0.02));
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 (
<View
style={[
styles.container,
{ paddingHorizontal: pad, paddingVertical: Math.round(pad * 0.65) },
]}
>
<View style={[styles.container, posStyle, { padding: pad }]}>
{/* Logo */}
{config.logoUri ? (
<>
<View
style={[
styles.logoWrap,
{ width: logoDim, height: logoDim, borderRadius: Math.round(logoDim * 0.08) },
{ width: logoDim, height: logoDim, borderRadius: Math.round(logoDim * 0.1), marginBottom: 4 },
]}
>
<Image source={{ uri: config.logoUri }} style={styles.logoImg} resizeMode="contain" />
</View>
<View style={[styles.divider, { marginHorizontal: pad, height: logoDim }]} />
</>
) : null}
{/* Campos */}
@@ -60,7 +65,7 @@ export function FooterOverlay({ config, meta, imageWidth }: Props) {
{ fontSize, lineHeight: lineH },
i === 0 ? styles.bold : null,
]}
numberOfLines={1}
numberOfLines={2}
>
{f.key === 'custom' ? `${f.label}: ${val}` : val}
</Text>
@@ -74,23 +79,20 @@ export function FooterOverlay({ config, meta, imageWidth }: Props) {
const styles = StyleSheet.create({
container: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: 'rgba(0,0,0,0.75)',
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.65)',
borderRadius: 8,
maxWidth: '70%', // Un poco más ancho para evitar cortes agresivos
alignItems: 'flex-start',
zIndex: 1000,
},
logoWrap: {
backgroundColor: '#fff',
overflow: 'hidden',
justifyContent: 'center',
alignItems: 'center',
flexShrink: 0,
},
logoImg: { width: '100%', height: '100%' },
divider: { width: 1, backgroundColor: 'rgba(255,255,255,0.35)', flexShrink: 0 },
textBlock: { flex: 1 },
logoImg: { width: '85%', height: '85%' },
textBlock: { width: '100%' },
line: { color: '#fff' },
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 { getMeta, setMeta } from '../db/repositories';
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 {
id: string;
@@ -17,6 +20,10 @@ export interface FooterConfig {
enabled: boolean;
logoUri: string | null;
fields: FooterField[];
resolution: PhotoResolution;
aspectRatio: PhotoAspectRatio;
quality: number; // 0.1 to 1.0
overlayPosition: OverlayPosition;
}
export interface StampMeta {
@@ -37,16 +44,30 @@ function defaultConfig(): FooterConfig {
{ id: 'date', key: 'date', label: 'Fecha', 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> {
const raw = await getMeta(META_KEY);
if (!raw) return defaultConfig();
const defaults = defaultConfig();
if (!raw) return defaults;
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 {
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 { getTemplates } from '../db/repositories';
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 { MediaStrip } from '../ui/MediaStrip';
import {
Card,
ChipSelect,
COLORS,
Field,
PrimaryButton,
SectionTitle,
@@ -75,6 +78,7 @@ function groupFields(fields: NormField[]): { group: string; items: NormField[] }
const RESULTS = ['pass', 'fail', 'na'] as const;
export function InspectionFormScreen({ route, navigation }: Props) {
const insets = useSafeAreaInsets();
const { featureId, featureName, templateId: suggestedId } = route.params;
// 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 [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(() => {
void getTemplates().then((all) => {
// La plantilla sugerida (la de la feature) primero.
@@ -102,12 +110,17 @@ export function InspectionFormScreen({ route, navigation }: Props) {
setTemplate(t);
setValues({});
setChosen(true);
// Generate IDs early so photos can be associated
setInspectionTempId(nextTempId());
setInspectionUuid(newUuid());
}, []);
const backToPicker = useCallback(() => {
setChosen(false);
setTemplate(null);
setValues({});
setInspectionTempId(null);
setInspectionUuid(null);
}, []);
const fields: NormField[] = (template?.fields ?? []).map(normalizeField);
@@ -133,6 +146,7 @@ export function InspectionFormScreen({ route, navigation }: Props) {
}
setSaving(true);
try {
// Create the inspection using the pre-generated IDs
await createInspection({
feature_id: featureId,
template_id: template?.id ?? undefined,
@@ -140,13 +154,15 @@ export function InspectionFormScreen({ route, navigation }: Props) {
result,
notes: notes.trim() || undefined,
status: 'completed',
uuid: inspectionUuid ?? undefined,
localId: inspectionTempId ?? undefined,
});
navigation.goBack();
} finally {
setSaving(false);
}
// 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 label = f.required ? `${f.label} *` : f.label;
@@ -189,7 +205,7 @@ export function InspectionFormScreen({ route, navigation }: Props) {
// ── Paso 1: selector de plantilla ──
if (!chosen) {
return (
<ScrollView contentContainerStyle={styles.body}>
<ScrollView contentContainerStyle={[styles.body, { paddingBottom: insets.bottom + 20 }]}>
<Text style={styles.subtitle}>{featureName}</Text>
<Text style={styles.tplName}>Elige una plantilla</Text>
@@ -220,22 +236,13 @@ export function InspectionFormScreen({ route, navigation }: Props) {
<Text style={styles.tplChevron}></Text>
</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>
);
}
// ── Paso 2: formulario ──
return (
<ScrollView contentContainerStyle={styles.body}>
<ScrollView contentContainerStyle={[styles.body, { paddingBottom: insets.bottom + 20 }]}>
<Text style={styles.subtitle}>{featureName}</Text>
<Text style={styles.tplName}>{template?.name ?? 'Inspección libre'}</Text>
<TouchableOpacity onPress={backToPicker}>
@@ -261,6 +268,17 @@ export function InspectionFormScreen({ route, navigation }: Props) {
/>
</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} />
<View style={{ height: 8 }} />
<PrimaryButton title="Cancelar" variant="ghost" onPress={() => navigation.goBack()} />
@@ -270,7 +288,7 @@ export function InspectionFormScreen({ route, navigation }: Props) {
const styles = StyleSheet.create({
body: { padding: 16 },
subtitle: { color: COLORS.muted, fontSize: 13 },
subtitle: { color: '#666', fontSize: 13 }, // muted
tplName: { fontSize: 18, fontWeight: '700', marginBottom: 12 },
switchRow: {
flexDirection: 'row',
@@ -279,21 +297,21 @@ const styles = StyleSheet.create({
paddingVertical: 10,
},
switchLabel: { fontSize: 15, flex: 1 },
help: { fontSize: 12, color: COLORS.muted, marginTop: -6, marginBottom: 8 },
changeTpl: { color: COLORS.primary, fontSize: 13, fontWeight: '600', marginBottom: 12 },
noTemplates: { color: COLORS.muted, fontSize: 14, marginVertical: 16, textAlign: 'center' },
help: { fontSize: 12, color: '#666', marginTop: -6, marginBottom: 8 }, // muted
changeTpl: { color: '#1f6f43', fontSize: 13, fontWeight: '600', marginBottom: 12 }, // primary
noTemplates: { color: '#666', fontSize: 14, marginVertical: 16, textAlign: 'center' }, // muted
tplCard: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderColor: COLORS.border,
borderColor: '#ddd', // border
borderRadius: 10,
padding: 14,
marginBottom: 8,
backgroundColor: '#fff',
},
tplCardName: { fontSize: 15, fontWeight: '700' },
tplCardDesc: { fontSize: 13, color: COLORS.muted, marginTop: 2 },
tplCardMeta: { fontSize: 11, color: COLORS.muted, marginTop: 4 },
tplChevron: { fontSize: 24, color: COLORS.muted, marginLeft: 8 },
tplCardDesc: { fontSize: 13, color: '#666', marginTop: 2 }, // muted
tplCardMeta: { fontSize: 11, color: '#666', marginTop: 4 }, // muted
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 { ScrollView, StyleSheet, Text, View } from 'react-native';
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() {
const [ops, setOps] = useState<ProblemOp[]>([]);
@@ -43,7 +43,7 @@ export function OutboxScreen() {
</Text>
<Badge
label={o.status}
color={o.status === 'conflict' ? COLORS.warn : COLORS.danger}
color={o.status === 'conflict' ? '#8a6d00' : '#b00020'}
/>
</View>
{o.error ? <Text style={styles.error}>{o.error}</Text> : null}
@@ -74,7 +74,7 @@ const styles = StyleSheet.create({
card: { gap: 8 },
headerRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
entity: { fontSize: 15, fontWeight: '700' },
error: { color: COLORS.danger, fontSize: 13 },
mono: { fontSize: 12, color: COLORS.muted, fontFamily: 'monospace' },
error: { color: '#b00020', fontSize: 13 }, // danger
mono: { fontSize: 12, color: '#666', fontFamily: 'monospace' }, // muted
actions: { flexDirection: 'row', gap: 8, marginTop: 4 },
});
+83 -19
View File
@@ -22,10 +22,13 @@ import {
FooterConfig,
FooterField,
loadFooterConfig,
OverlayPosition,
pickAndSaveLogo,
PhotoResolution,
PhotoAspectRatio,
saveFooterConfig,
} from '../photo/footerConfig';
import { COLORS, PrimaryButton, SectionTitle } from '../ui/components';
import { ChipSelect, PrimaryButton, SectionTitle } from '../ui/components';
const BUILT_IN_LABELS: Record<string, string> = {
project_name: 'Nombre del proyecto',
@@ -33,6 +36,11 @@ const BUILT_IN_LABELS: Record<string, string> = {
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() {
const [config, setConfig] = useState<FooterConfig | null>(null);
const [newLabel, setNewLabel] = useState('');
@@ -119,13 +127,12 @@ export function PhotoSettingsScreen() {
return (
<ScrollView contentContainerStyle={styles.body} keyboardShouldPersistTaps="handled">
{/* Master toggle */}
<View style={styles.row}>
<Text style={styles.masterLabel}>Añadir pie de página a las fotos</Text>
<Switch
value={config.enabled}
onValueChange={toggleEnabled}
trackColor={{ true: COLORS.primary }}
trackColor={{ true: '#1f6f43' }} // primary
/>
</View>
@@ -154,6 +161,56 @@ export function PhotoSettingsScreen() {
Recorte cuadrado. Se recomienda logo con fondo blanco.
</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 ── */}
<SectionTitle>Campos</SectionTitle>
@@ -164,7 +221,7 @@ export function PhotoSettingsScreen() {
<Switch
value={f.enabled}
onValueChange={(v) => toggleField(f.id, v)}
trackColor={{ true: COLORS.primary }}
trackColor={{ true: '#1f6f43' }} // primary
/>
<View style={styles.fieldInfo}>
<Text style={styles.fieldKey}>
@@ -255,27 +312,34 @@ const styles = StyleSheet.create({
alignItems: 'center',
paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
borderColor: COLORS.border,
borderColor: '#ddd', // border
marginBottom: 8,
},
masterLabel: { fontSize: 16, fontWeight: '600', flex: 1, marginRight: 12 },
/* Logo */
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: {
width: 72,
height: 72,
borderRadius: 8,
backgroundColor: COLORS.bg,
backgroundColor: '#f4f4f4', // bg
justifyContent: 'center',
alignItems: 'center',
borderWidth: StyleSheet.hairlineWidth,
borderColor: COLORS.border,
borderColor: '#ddd', // border
},
logoPlaceholderText: { fontSize: 11, color: COLORS.muted },
logoPlaceholderText: { fontSize: 11, color: '#666' }, // muted
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 */
fieldRow: {
@@ -284,21 +348,21 @@ const styles = StyleSheet.create({
gap: 10,
paddingVertical: 10,
borderBottomWidth: StyleSheet.hairlineWidth,
borderColor: COLORS.border,
borderColor: '#ddd', // border
},
fieldInfo: { flex: 1 },
fieldKey: { fontSize: 11, color: COLORS.muted, marginBottom: 2 },
fieldKey: { fontSize: 11, color: '#666', marginBottom: 2 }, // muted
fieldLabel: {
fontSize: 14,
color: '#111',
borderBottomWidth: 1,
borderColor: COLORS.border,
borderColor: '#ddd', // border
paddingVertical: 2,
paddingHorizontal: 0,
},
fieldValue: { marginTop: 4, color: COLORS.muted },
fieldValue: { marginTop: 4, color: '#666' }, // muted
deleteBtn: { padding: 8 },
deleteTxt: { color: COLORS.danger, fontSize: 16 },
deleteTxt: { color: '#b00020', fontSize: 16 }, // danger
/* Nuevo campo */
addFieldBtn: {
@@ -307,20 +371,20 @@ const styles = StyleSheet.create({
alignItems: 'center',
borderWidth: 1,
borderStyle: 'dashed',
borderColor: COLORS.primary,
borderColor: '#1f6f43', // primary
borderRadius: 8,
},
addFieldTxt: { color: COLORS.primary, fontWeight: '600' },
addFieldTxt: { color: '#1f6f43', fontWeight: '600' }, // primary
addForm: {
marginTop: 12,
padding: 12,
backgroundColor: COLORS.bg,
backgroundColor: '#f4f4f4', // bg
borderRadius: 8,
gap: 10,
},
addInput: {
borderWidth: 1,
borderColor: COLORS.border,
borderColor: '#ddd', // border
borderRadius: 6,
paddingHorizontal: 10,
paddingVertical: 8,
+20 -10
View File
@@ -7,8 +7,9 @@ import { getOutboxCounts, OutboxCounts } from '../db/outbox';
import { isOnline } from '../net/connectivity';
import { runSync } from '../sync/engine';
import { useAutoSync } from '../sync/useAutoSync';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { RootStackParamList } from '../navigation/types';
import { COLORS, PrimaryButton } from '../ui/components';
import { PrimaryButton } from '../ui/components';
import { FeaturesSection } from './sections/FeaturesSection';
import { IssuesSection } from './sections/IssuesSection';
@@ -19,8 +20,10 @@ const TABS = ['Features', 'Incidencias'] as const;
type Tab = (typeof TABS)[number];
export function ProjectDetailScreen({ route, navigation }: Props) {
const insets = useSafeAreaInsets();
const { projectId } = route.params;
const [tab, setTab] = useState<Tab>('Features');
const [featuresMode, setFeaturesMode] = useState<'list' | 'map'>('list');
const [counts, setCounts] = useState<OutboxCounts>(EMPTY);
const [syncing, setSyncing] = useState(false);
// 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 style={styles.content} key={`${tab}-${nonce}`}>
{tab === 'Features' && <FeaturesSection projectId={projectId} />}
{tab === 'Incidencias' && <IssuesSection projectId={projectId} />}
<View style={styles.content} key={tab}>
{tab === 'Features' && (
<FeaturesSection
projectId={projectId}
refreshKey={nonce}
mode={featuresMode}
onModeChange={setFeaturesMode}
/>
)}
{tab === 'Incidencias' && <IssuesSection projectId={projectId} refreshKey={nonce} />}
</View>
<View style={styles.footer}>
<View style={[styles.footer, { paddingBottom: Math.max(12, insets.bottom) }]}>
<PrimaryButton
title={syncing ? 'Sincronizando…' : 'Sincronizar'}
onPress={() => void onSync()}
@@ -120,11 +130,11 @@ export function ProjectDetailScreen({ route, navigation }: Props) {
const styles = StyleSheet.create({
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' },
tabActive: { borderBottomWidth: 2, borderColor: COLORS.primary },
tabText: { fontSize: 14, color: COLORS.muted },
tabTextActive: { color: COLORS.primary, fontWeight: '700' },
tabActive: { borderBottomWidth: 2, borderColor: '#1f6f43' }, // primary
tabText: { fontSize: 14, color: '#666' }, // muted
tabTextActive: { color: '#1f6f43', fontWeight: '700' }, // primary
content: { flex: 1 },
footer: { padding: 12, borderTopWidth: StyleSheet.hairlineWidth, borderColor: COLORS.border },
footer: { padding: 12, borderTopWidth: StyleSheet.hairlineWidth, borderColor: '#ddd' }, // border
});
+116 -34
View File
@@ -1,5 +1,5 @@
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
FlatList,
@@ -17,6 +17,8 @@ import { isOnline } from '../net/connectivity';
import { runSync } from '../sync/engine';
import { RootStackParamList } from '../navigation/types';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
type Props = NativeStackScreenProps<RootStackParamList, 'Projects'>;
/** 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) {
const insets = useSafeAreaInsets();
const { user, signOut } = useSession();
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(false);
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 () => {
setLoading(true);
@@ -38,9 +62,6 @@ export function ProjectsScreen({ navigation }: Props) {
if (await isOnline()) {
const list = normalize(await api.listProjects());
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 {
const { templates } = await api.getTemplates();
await saveTemplates(templates);
@@ -66,8 +87,6 @@ export function ProjectsScreen({ navigation }: Props) {
setOpening(p.id);
try {
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);
navigation.navigate('ProjectDetail', { projectId: p.id, name: p.name });
} finally {
@@ -77,27 +96,48 @@ export function ProjectsScreen({ navigation }: Props) {
[navigation],
);
const handleLogout = () => {
setShowMenu(false);
signOut();
};
const handleSettings = () => {
setShowMenu(false);
navigation.navigate('Settings');
};
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.hello}>Hola, {user?.name ?? ''}</Text>
<TouchableOpacity onPress={signOut}>
<Text style={styles.logout}>Salir</Text>
<View style={[styles.container, { paddingBottom: insets.bottom }]}>
{/* Menu flotante (se posiciona relativo al contenedor principal) */}
{showMenu && (
<View style={styles.floatingMenu}>
<TouchableOpacity style={styles.dropdownItem} onPress={handleSettings} activeOpacity={0.7}>
<Text style={styles.dropdownItemText}> Configuración</Text>
</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>
}
ListEmptyComponent={loading ? null : <Text style={styles.empty}>No hay proyectos.</Text>}
renderItem={({ item }) => (
<TouchableOpacity style={styles.row} onPress={() => openProject(item)}>
<View style={{ flex: 1 }}>
<Text style={styles.name}>{item.name}</Text>
{item.reference ? <Text style={styles.ref}>{item.reference}</Text> : null}
<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 />
@@ -107,30 +147,72 @@ export function ProjectsScreen({ navigation }: Props) {
</TouchableOpacity>
)}
/>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
container: { flex: 1, backgroundColor: '#fff' },
headerRight: {
marginRight: 8,
},
hello: { fontSize: 16, fontWeight: '600' },
logout: { color: '#b00020', fontWeight: '600' },
row: {
flexDirection: 'row',
alignItems: 'center',
userButton: {
paddingHorizontal: 8,
paddingVertical: 4,
},
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,
paddingVertical: 14,
borderTopWidth: StyleSheet.hairlineWidth,
borderColor: '#ddd',
},
name: { fontSize: 16, fontWeight: '600' },
ref: { fontSize: 13, color: '#666', marginTop: 2 },
chevron: { fontSize: 24, color: '#999' },
dropdownItemDanger: {
borderTopWidth: StyleSheet.hairlineWidth,
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' },
});
+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 { hasPermission, useSession } from '../../auth/session';
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 {
Badge,
Card,
ChipSelect,
COLORS,
EmptyState,
PrimaryButton,
SectionTitle,
} 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>;
export function FeatureDetailContent({ featureId }: { featureId: number }) {
const insets = useSafeAreaInsets();
const navigation = useNavigation<Nav>();
const { user } = useSession();
const canProgress = hasPermission(user, 'update progress');
@@ -37,6 +27,34 @@ export function FeatureDetailContent({ featureId }: { featureId: number }) {
const [inspections, setInspections] = useState<Inspection[]>([]);
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 [f, ins, types] = await Promise.all([
getFeature(featureId),
@@ -52,34 +70,10 @@ export function FeatureDetailContent({ featureId }: { featureId: number }) {
void 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) {
return (
<View style={styles.center}>
<Text style={{ color: COLORS.muted }}>Cargando</Text>
<Text style={{ color: '#666' }}>Cargando</Text>
</View>
);
}
@@ -88,57 +82,14 @@ export function FeatureDetailContent({ featureId }: { featureId: number }) {
const isActive = feature.is_active == null || Number(feature.is_active) !== 0;
return (
<ScrollView contentContainerStyle={styles.body}>
<Text style={styles.title}>{feature.name}</Text>
<ScrollView contentContainerStyle={[styles.body, { paddingBottom: insets.bottom + 20 }]}>
<View style={styles.badges}>
{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={COLORS.muted} />
{!isActive && <Badge label="inactiva" color={COLORS.danger} />}
<Badge label={`${Math.round(feature.progress ?? 0)}%`} color={'#666'} />
</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>
{inspections.length === 0 && <EmptyState text="Sin inspecciones." />}
{inspections.map((ins) => (
@@ -150,19 +101,6 @@ export function FeatureDetailContent({ featureId }: { featureId: number }) {
{ins.notes ? <Text style={styles.insNotes}>{ins.notes}</Text> : null}
</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>
);
}
@@ -170,29 +108,20 @@ export function FeatureDetailContent({ featureId }: { featureId: number }) {
const styles = StyleSheet.create({
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
body: { padding: 16, gap: 6 },
title: { fontSize: 20, fontWeight: '700' },
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 },
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,
Card,
ChipSelect,
COLORS,
EmptyState,
Field,
ISSUE_PRIORITY_COLOR,
@@ -111,7 +110,7 @@ export function IssueDetailContent({ issueId }: { issueId: number }) {
if (!issue) {
return (
<View style={styles.center}>
<Text style={{ color: COLORS.muted }}>Cargando</Text>
<Text style={{ color: '#666' }}>Cargando</Text>
</View>
);
}
@@ -124,15 +123,15 @@ export function IssueDetailContent({ issueId }: { issueId: number }) {
<Text style={styles.title}>{issue.title}</Text>
<View style={styles.badges}>
{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 && (
<Badge
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>
{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 },
checkbox: { fontSize: 20 },
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 },
comment: { marginBottom: 6 },
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 { 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 { Feature } from '../../api/types';
import { getFeatures } from '../../db/repositories';
import { RootStackParamList } from '../../navigation/types';
import { Badge, COLORS, EmptyState } from '../../ui/components';
import { Badge, EmptyState } from '../../ui/components';
import { FeatureMap } from '../../ui/FeatureMap';
import { MasterDetail } from '../../ui/MasterDetail';
import { FeatureDetailContent } from '../detail/FeatureDetailContent';
@@ -18,10 +18,19 @@ import { FeatureDetailContent } from '../detail/FeatureDetailContent';
type Nav = NativeStackNavigationProp<RootStackParamList>;
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 [features, setFeatures] = useState<Feature[]>([]);
const [mode, setMode] = useState<ViewMode>('list');
const load = useCallback(() => {
void getFeatures(projectId).then(setFeatures);
@@ -29,6 +38,12 @@ export function FeaturesSection({ projectId }: { projectId: number }) {
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 f = features.find((x) => x.id === id);
navigation.navigate('FeatureDetail', { featureId: id, name: f?.name ?? 'Feature' });
@@ -45,7 +60,7 @@ export function FeaturesSection({ projectId }: { projectId: number }) {
<TouchableOpacity
key={m}
style={[styles.toggleBtn, mode === m && styles.toggleActive]}
onPress={() => setMode(m)}
onPress={() => onModeChange(m)}
>
<Text style={[styles.toggleText, mode === m && styles.toggleTextActive]}>
{m === 'list' ? 'Lista' : 'Mapa'}
@@ -71,7 +86,7 @@ export function FeaturesSection({ projectId }: { projectId: number }) {
<Text style={styles.name}>{item.name}</Text>
{item.status ? <Text style={styles.meta}>{item.status}</Text> : null}
</View>
<Badge label={`${Math.round(item.progress ?? 0)}%`} color={COLORS.muted} />
<Badge label={`${Math.round(item.progress ?? 0)}%`} color="#666" />
</TouchableOpacity>
)}
/>
@@ -88,17 +103,17 @@ const styles = StyleSheet.create({
padding: 8,
gap: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
borderColor: COLORS.border,
borderColor: '#ddd', // border
},
toggleBtn: {
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 16,
borderWidth: 1,
borderColor: COLORS.border,
borderColor: '#ddd', // border
},
toggleActive: { backgroundColor: COLORS.primary, borderColor: COLORS.primary },
toggleText: { fontSize: 13, color: COLORS.muted },
toggleActive: { backgroundColor: '#1f6f43', borderColor: '#1f6f43' }, // primary
toggleText: { fontSize: 13, color: '#666' }, // muted
toggleTextActive: { color: '#fff', fontWeight: '700' },
row: {
flexDirection: 'row',
@@ -106,10 +121,10 @@ const styles = StyleSheet.create({
paddingHorizontal: 16,
paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
borderColor: COLORS.border,
borderColor: '#ddd', // border
gap: 8,
},
rowActive: { backgroundColor: '#eef5f0' },
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 { 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 { Issue } from '../../api/types';
import { hasPermission, useSession } from '../../auth/session';
@@ -11,7 +11,6 @@ import { getIssues } from '../../db/repositories';
import { RootStackParamList } from '../../navigation/types';
import {
Badge,
COLORS,
EmptyState,
ISSUE_PRIORITY_COLOR,
ISSUE_STATUS_COLOR,
@@ -22,7 +21,7 @@ import { IssueDetailContent } from '../detail/IssueDetailContent';
type Nav = NativeStackNavigationProp<RootStackParamList>;
export function IssuesSection({ projectId }: { projectId: number }) {
export function IssuesSection({ projectId, refreshKey }: { projectId: number; refreshKey?: number }) {
const navigation = useNavigation<Nav>();
const { user } = useSession();
const canCreate = hasPermission(user, 'create issues');
@@ -34,6 +33,10 @@ export function IssuesSection({ projectId }: { projectId: number }) {
useFocusEffect(load);
useEffect(() => {
if (refreshKey) void load();
}, [refreshKey, load]);
return (
<View style={{ flex: 1 }}>
{canCreate && (
@@ -71,16 +74,16 @@ export function IssuesSection({ projectId }: { projectId: number }) {
{item.priority && (
<Badge
label={item.priority}
color={ISSUE_PRIORITY_COLOR[item.priority] ?? COLORS.muted}
color={ISSUE_PRIORITY_COLOR[item.priority] ?? '#666'}
/>
)}
{item.status && (
<Badge
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>
</TouchableOpacity>
@@ -94,12 +97,12 @@ export function IssuesSection({ projectId }: { projectId: number }) {
}
const styles = StyleSheet.create({
toolbar: { padding: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: COLORS.border },
toolbar: { padding: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: '#ddd' }, // border
row: {
paddingHorizontal: 16,
paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
borderColor: COLORS.border,
borderColor: '#ddd', // border
},
rowActive: { backgroundColor: '#eef5f0' },
title: { fontSize: 15, fontWeight: '600' },
+25
View File
@@ -239,3 +239,28 @@ export async function runSync(projectId: number): Promise<SyncReport> {
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;
result?: string;
notes?: string;
uuid?: string;
localId?: number;
}): Promise<string> {
const uuid = newUuid();
const tempId = nextTempId();
const uuid = input.uuid ?? newUuid();
const tempId = input.localId ?? nextTempId();
await insertLocalCreate('inspection', tempId, uuid, {
feature_id: input.feature_id,
template_id: input.template_id ?? null,
+219 -113
View File
@@ -1,32 +1,13 @@
/**
* Mapa de features. Dibuja la geometría GeoJSON del proyecto (puntos, líneas,
* polígonos) sobre Google Maps y permite seleccionar una feature tocándola.
*
* 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).
* Mapa de features usando OpenStreetMap via Leaflet y WebView.
* Elimina la dependencia de la API Key de Google Maps.
* Añade ubicación del usuario y mapa satelital.
*/
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 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 { 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({
features,
@@ -37,113 +18,238 @@ export function FeatureMap({
selectedId: number | null;
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(() => {
const all: LatLng[] = [];
const shaped = features.map((f) => {
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]);
// Vigilancia de la ubicación del usuario
useEffect(() => {
let sub: Location.LocationSubscription | null = null;
const recenter = async () => {
const perm = await Location.requestForegroundPermissionsAsync();
if (!perm.granted) return;
const pos = await Location.getCurrentPositionAsync({});
mapRef.current?.animateToRegion({
latitude: pos.coords.latitude,
longitude: pos.coords.longitude,
latitudeDelta: 0.01,
longitudeDelta: 0.01,
(async () => {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') return;
const last = await Location.getLastKnownPositionAsync();
if (last) setUserLocation(last.coords);
sub = await Location.watchPositionAsync(
{ 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'
};
if (!region) {
return (
<View style={styles.empty}>
<Text style={{ color: COLORS.muted }}>Las features no tienen geometría.</Text>
</View>
);
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);
}
};
return (
<View style={styles.container}>
<MapView
ref={mapRef}
style={StyleSheet.absoluteFill}
provider={MAP_PROVIDER}
initialRegion={region}
showsUserLocation
>
{shaped.map(({ feature, shapes }) => {
const color = colorFor(feature.status);
const selected = feature.id === selectedId;
const stroke = selected ? '#000' : color;
return (
<React.Fragment key={feature.id}>
{shapes.points.map((p, i) => (
<Marker
key={`pt${feature.id}-${i}`}
coordinate={p}
pinColor={color}
title={feature.name}
onPress={() => onSelect(feature.id)}
<WebView
ref={webViewRef}
originWhitelist={['*']}
source={{ html: htmlContent }}
onMessage={onMessage}
style={styles.map}
javaScriptEnabled={true}
domStorageEnabled={true}
/>
))}
{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>
{userLocation && (
<TouchableOpacity style={styles.fab} onPress={centerOnUser} activeOpacity={0.8}>
<Text style={styles.fabIcon}>🎯</Text>
</TouchableOpacity>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
empty: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
locBtn: {
container: { flex: 1, backgroundColor: '#f0f0f0' },
map: { flex: 1 },
fab: {
position: 'absolute',
right: 16,
bottom: 16,
width: 48,
height: 48,
borderRadius: 24,
bottom: 24,
right: 24,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: '#fff',
elevation: 6,
shadowColor: '#000',
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.3,
shadowRadius: 4,
justifyContent: 'center',
alignItems: 'center',
elevation: 4,
shadowColor: '#000',
shadowOpacity: 0.2,
shadowRadius: 4,
shadowOffset: { width: 0, height: 2 },
borderWidth: StyleSheet.hairlineWidth,
borderColor: '#ddd',
},
locIcon: { fontSize: 22, color: COLORS.primary },
fabIcon: { fontSize: 24 },
});
+236 -140
View File
@@ -1,21 +1,26 @@
/**
* 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
* o galería; antes de encolarlas les estampa un pie de página georreferenciado
* (logo + proyecto + fecha + coordenadas) capturado con react-native-view-shot.
* y las locales en cola (`media_outbox`). Permite añadir nuevas desde cámara;
* antes de encolarlas les estampa un pie de página georreferenciado.
*
* 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 React, { useCallback, useEffect, useRef, useState } from 'react';
import {
Alert,
Image,
Modal,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
useWindowDimensions,
View,
} from 'react-native';
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 { Media, MediaParentEntity } from '../api/types';
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 { RootStackParamList } from '../navigation/types';
import {
FooterConfig,
loadFooterConfig,
RESOLUTION_WIDTHS,
resolveFieldValue,
StampMeta,
} from '../photo/footerConfig';
import { FooterOverlay } from '../photo/FooterOverlay';
import { COLORS } from './components';
// ─── tipos internos ────────────────────────────────────────────────────────
interface ComposingTask {
uuid: string; // Para saber qué registro actualizar al terminar
uri: string;
renderW: number;
renderH: number;
@@ -47,17 +53,13 @@ interface ComposingTask {
type Nav = NativeStackNavigationProp<RootStackParamList>;
const MAX_STAMP_W = 2048;
// ─── helpers ───────────────────────────────────────────────────────────────
async function getCoords(): Promise<{ latitude: number; longitude: number } | null> {
try {
const { granted } = await Location.requestForegroundPermissionsAsync();
if (!granted) return null;
const loc = await Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.Balanced,
});
const loc = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced });
return loc.coords;
} catch {
return null;
@@ -82,13 +84,20 @@ export function MediaStrip({
canUpload: boolean;
}) {
const navigation = useNavigation<Nav>();
const { width: winW } = useWindowDimensions();
const [synced, setSynced] = useState<Media[]>([]);
const [pending, setPending] = useState<MediaOutboxRow[]>([]);
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 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 ──────────────────────────────────────────────────────────
@@ -103,43 +112,51 @@ export function MediaStrip({
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(() => {
// Un frame para que el layout nativo finalice antes de capturar.
if (!composing) return;
setTimeout(async () => {
if (!captureViewRef.current) {
rejectRef.current?.(new Error('captureRef is null'));
setComposing(null);
return;
}
try {
const uri = await captureRef(captureViewRef, { format: 'jpg', quality: 0.85 });
resolveRef.current?.(uri);
const stampedUri = await captureRef(captureViewRef, {
format: 'jpg',
quality: composing.config.quality
});
// Actualizar el registro local con la imagen ya sellada
await updateMediaLocalUri(composing.uuid, stampedUri);
await refresh();
} catch (e) {
rejectRef.current?.(e);
console.error('Stamping failed:', e);
} finally {
setComposing(null);
resolveRef.current = null;
rejectRef.current = null;
setComposing(null); // Esto disparará el siguiente en la cola vía useEffect
}
}, 80);
}, []);
}, 100);
}, [composing, refresh]);
/**
* Estampa el pie de página sobre `rawUri`.
* Si el footer está desactivado o vacío devuelve el URI original intacto.
*/
async function stamp(rawUri: string, origW: number, origH: number): Promise<string> {
/** Prepara una tarea de estampado y la mete en la cola. */
async function scheduleStamp(uuid: string, rawUri: string, origW: number, origH: number) {
const [config, projectId] = await Promise.all([
loadFooterConfig(),
getActiveProjectId(),
]);
const hasContent = config.enabled &&
(config.logoUri != null || config.fields.some((f) => f.enabled));
if (!hasContent) return rawUri;
if (!config.enabled) return;
let projectName: string | null = null;
if (projectId != null) {
@@ -157,127 +174,172 @@ export function MediaStrip({
coordinates: coordsRaw ? formatCoords(coordsRaw.latitude, coordsRaw.longitude) : null,
};
const scale = Math.min(1, MAX_STAMP_W / origW);
const renderW = Math.round(origW * scale);
const renderH = Math.round(origH * scale);
const targetW = RESOLUTION_WIDTHS[config.resolution] || 1920;
const scale = Math.min(1, targetW / origW);
return new Promise<string>((resolve, reject) => {
resolveRef.current = resolve;
rejectRef.current = reject;
setComposing({ uri: rawUri, renderW, renderH, meta, config });
queueRef.current.push({
uuid,
uri: rawUri,
renderW: Math.round(origW * scale),
renderH: Math.round(origH * scale),
meta,
config,
});
processNext();
}
// ── captura de foto ───────────────────────────────────────────────────────
// ── acciones ──────────────────────────────────────────────────────────────
const addPhoto = useCallback(
async (source: 'camera' | 'library') => {
const perm =
source === 'camera'
? await ImagePicker.requestCameraPermissionsAsync()
: await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) {
Alert.alert('Permiso necesario', 'Concede el permiso para añadir fotos.');
return;
}
const result =
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];
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({
const handleCapture = async (uri: string, width: number, height: number) => {
// 1. Guardar la imagen original inmediatamente para respuesta instantánea
const uuid = await enqueueMedia({
parentEntity,
parentId,
localUri: finalUri,
fileName: asset.fileName ?? undefined,
mimeType: asset.mimeType ?? 'image/jpeg',
localUri: uri,
mimeType: 'image/jpeg',
category: 'image',
});
await refresh();
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[parentEntity, parentId, refresh],
);
await refresh(); // Mostrar en la galería ya
// 2. Programar el estampado en segundo plano
void scheduleStamp(uuid, uri, width, height);
};
const onAdd = useCallback(() => {
Alert.alert('Añadir foto', undefined, [
{ text: 'Cámara', onPress: () => void addPhoto('camera') },
{ text: 'Galería', onPress: () => void addPhoto('library') },
navigation.navigate('Camera', { onCapture: handleCapture });
}, [navigation, handleCapture]);
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: 'Eliminar',
style: 'destructive',
onPress: async () => {
for (const uuid of selectedUuids) {
await deleteMediaOutbox(uuid);
}
setSelectedUuids(new Set());
await refresh();
},
},
]);
}, [addPhoto]);
};
// ─── render ───────────────────────────────────────────────────────────────
const isSelectionMode = selectedUuids.size > 0;
const thumbSize = (winW - 32 - 16) / 3;
if (synced.length === 0 && pending.length === 0 && !canUpload) return null;
return (
<View style={styles.wrapper}>
{/* Cabecera con icono de configuración */}
{canUpload && (
<View style={styles.header}>
<Text style={styles.headerLabel}>Fotos</Text>
<Text style={styles.headerLabel}>
{isSelectionMode ? `${selectedUuids.size} seleccionadas` : `Fotos (${synced.length + pending.length})`}
</Text>
<View style={styles.headerActions}>
{isSelectionMode ? (
<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 */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.row}
>
<View style={styles.grid}>
{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.addText}>Foto</Text>
<Text style={styles.addText}>Añadir</Text>
</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 */}
{composing ? (
{pending.map((m) => {
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
ref={captureViewRef}
collapsable={false}
style={{
position: 'absolute',
top: 0,
left: -(composing.renderW + 100),
left: -(composing.renderW + 500),
width: composing.renderW,
height: composing.renderH,
overflow: 'hidden',
@@ -285,55 +347,75 @@ export function MediaStrip({
>
<Image
source={{ uri: composing.uri }}
style={StyleSheet.absoluteFill}
style={{ width: composing.renderW, height: composing.renderH }}
resizeMode="stretch"
onLoad={onCaptureImageLoaded}
/>
<FooterOverlay
config={composing.config}
meta={composing.meta}
imageWidth={composing.renderW}
/>
<FooterOverlay config={composing.config} meta={composing.meta} imageWidth={composing.renderW} />
</View>
) : null}
)}
</View>
);
}
// ─── estilos ──────────────────────────────────────────────────────────────
const styles = StyleSheet.create({
wrapper: { marginVertical: 8 },
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 2,
marginBottom: 6,
paddingHorizontal: 4,
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: {
width: 72,
height: 72,
borderRadius: 8,
borderWidth: 1,
borderColor: COLORS.primary,
borderColor: '#1f6f43', // primary
borderStyle: 'dashed',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#fff',
},
addPlus: { color: COLORS.primary, fontSize: 22, fontWeight: '700' },
addText: { color: COLORS.primary, fontSize: 11 },
addPlus: { color: '#1f6f43', fontSize: 24, fontWeight: '700' }, // primary
addText: { color: '#1f6f43', fontSize: 11, marginTop: 2 }, // primary
thumbWrap: {
width: 72,
height: 72,
borderRadius: 8,
overflow: 'hidden',
backgroundColor: COLORS.bg,
backgroundColor: '#f4f4f4', // bg
borderWidth: 2,
borderColor: 'transparent',
},
selectedThumb: { borderColor: '#1f6f43' }, // primary
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: {
position: 'absolute',
bottom: 0,
@@ -343,4 +425,18 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
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';
}) {
const bg =
variant === 'danger' ? COLORS.danger : variant === 'ghost' ? 'transparent' : COLORS.primary;
const fg = variant === 'ghost' ? COLORS.primary : '#fff';
variant === 'danger' ? '#b00020' : variant === 'ghost' ? 'transparent' : '#1f6f43';
const fg = variant === 'ghost' ? '#1f6f43' : '#fff';
return (
<TouchableOpacity
style={[
@@ -155,21 +155,21 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
buttonGhost: { borderWidth: 1, borderColor: COLORS.primary },
buttonGhost: { borderWidth: 1, borderColor: '#1f6f43' },
buttonDisabled: { opacity: 0.5 },
buttonText: { fontSize: 15, fontWeight: '700' },
card: {
backgroundColor: COLORS.bg,
backgroundColor: '#f4f4f4',
borderRadius: 10,
padding: 12,
},
sectionTitle: { fontSize: 16, fontWeight: '700', marginTop: 8, marginBottom: 4 },
empty: { color: '#888', textAlign: 'center', marginTop: 24 },
field: { marginBottom: 12 },
fieldLabel: { fontSize: 13, color: COLORS.muted, marginBottom: 4, fontWeight: '600' },
fieldLabel: { fontSize: 13, color: '#666', marginBottom: 4, fontWeight: '600' },
input: {
borderWidth: 1,
borderColor: COLORS.border,
borderColor: '#ddd',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
@@ -182,10 +182,10 @@ const styles = StyleSheet.create({
paddingVertical: 6,
borderRadius: 16,
borderWidth: 1,
borderColor: COLORS.border,
borderColor: '#ddd',
backgroundColor: '#fff',
},
chipActive: { backgroundColor: COLORS.primary, borderColor: COLORS.primary },
chipText: { fontSize: 13, color: COLORS.muted },
chipActive: { backgroundColor: '#1f6f43', borderColor: '#1f6f43' },
chipText: { fontSize: 13, color: '#666' },
chipTextActive: { color: '#fff', fontWeight: '700' },
});