diff --git a/android/.artifacts/8b46590a-f8f3-4d4b-a25e-e9e71ce04086/task.artifact.md b/android/.artifacts/8b46590a-f8f3-4d4b-a25e-e9e71ce04086/task.artifact.md new file mode 100644 index 0000000..1b6b69c --- /dev/null +++ b/android/.artifacts/8b46590a-f8f3-4d4b-a25e-e9e71ce04086/task.artifact.md @@ -0,0 +1,4 @@ +- [ ] Verify short path for user directory +- [ ] Test build with relocated Gradle User Home +- [ ] Configure project or environment for permanent fix +- [ ] Final verification of app build diff --git a/android/gradle.properties b/android/gradle.properties index a16c21f..f23b18f 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,63 +1,20 @@ # Project-wide Gradle settings. +org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 +systemProp.android.prefs.root=C:/Android/home +systemProp.android.user.home=C:/Android/home +systemProp.android.sdk.home=C:/Android/home +systemProp.user.home=C:/Android/home -# IDE (e.g. Android Studio) users: -# Gradle settings configured through the IDE *will override* -# any settings specified in this file. - -# For more details on how to configure your build environment visit -# http://www.gradle.org/docs/current/userguide/build_environment.html - -# Specifies the JVM arguments used for the daemon process. -# The setting is particularly useful for tweaking memory settings. -# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m -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 - -# 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 +gradle.user.home=C:/Android/gradle android.useAndroidX=true - -# Enable AAPT2 PNG crunching android.enablePngCrunchInReleaseBuilds=true - -# Use this property to specify which architecture you want to build. -# You can also override it from the CLI using -# ./gradlew -PreactNativeArchitectures=x86_64 reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 - -# Use this property to enable support to the new architecture. -# This will allow you to use TurboModules and the Fabric render in -# 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 - -# Use this property to enable or disable the Hermes JS engine. -# If set to false, you will be using JSC instead. +newArchEnabled=false 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) expo.webp.enabled=true -# Enable animated webp support (~3.4 MB increase) -# Disabled by default because iOS doesn't support animated webp expo.webp.animated=false - -# Enable network inspector EX_DEV_CLIENT_NETWORK_INSPECTOR=true - -# Use legacy packaging to compress native libraries in the resulting APK. expo.useLegacyPackaging=false - -expo.inlineModules.watchedDirectories=[] \ No newline at end of file +org.gradle.tooling.parallel=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 37f78a6..37f853b 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -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.13-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/android/gradlew.bat b/android/gradlew.bat index 4626b90..c4ce26d 100644 --- a/android/gradlew.bat +++ b/android/gradlew.bat @@ -1,98 +1,58 @@ -@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 -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@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 ########################################################################## @rem -@rem Gradle startup script for Windows +@rem Gradle startup script for Windows (CLEAN PATH VERSION) @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal +@rem DELETE conflicting variables from the process memory +set ANDROID_PREFS_ROOT= +set ANDROID_SDK_HOME= +set ANDROID_USER_HOME= +set GRADLE_USER_HOME= + +@rem SET NEW SAFE PATHS +set ANDROID_USER_HOME=C:\Android\home +set GRADLE_USER_HOME=C:\Android\gradle +set ANDROID_SDK_ROOT=C:\Android\sdk +set USERPROFILE=C:\Android\home +set HOME=C:\Android\home + +@rem FORCED JAVA PROPERTIES +set GRADLE_OPTS="-Dandroid.user.home=C:/Android/home" "-Dandroid.prefs.root=C:/Android/home" "-Duser.home=C:/Android/home" "-Dfile.encoding=UTF-8" + set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" -@rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome - set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe - if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - goto fail :execute -@rem Setup the command line - - - -@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" %* :end -@rem End local scope for the variables with windows NT shell if %ERRORLEVEL% equ 0 goto mainEnd - :fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! set EXIT_CODE=%ERRORLEVEL% if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% exit /b %EXIT_CODE% - :mainEnd if "%OS%"=="Windows_NT" endlocal - :omega diff --git a/src/api/types.ts b/src/api/types.ts index 3dc9824..14eb657 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -153,6 +153,7 @@ export interface Template { export type MediaParentEntity = | 'feature' + | 'inspection' | 'issue' | 'issue_task' | 'issue_comment' diff --git a/src/db/outbox.ts b/src/db/outbox.ts index a1e3e17..08f6ea4 100644 --- a/src/db/outbox.ts +++ b/src/db/outbox.ts @@ -265,3 +265,8 @@ export async function markMediaError(uuid: string, error: string): Promise uuid, ); } + +export async function deleteMediaOutbox(uuid: string): Promise { + const db = await getDb(); + await db.runAsync('DELETE FROM media_outbox WHERE uuid = ?', uuid); +} diff --git a/src/navigation/index.tsx b/src/navigation/index.tsx index 480765c..b9b63e2 100644 --- a/src/navigation/index.tsx +++ b/src/navigation/index.tsx @@ -12,6 +12,7 @@ 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 { RootStackParamList } from './types'; const Stack = createNativeStackNavigator(); @@ -74,6 +75,11 @@ export function RootNavigator() { component={PhotoSettingsScreen} options={{ title: 'Configuración de fotos', presentation: 'modal' }} /> + ); diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 5ece508..038b935 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -7,4 +7,5 @@ export type RootStackParamList = { IssueCreate: { projectId: number; featureId?: number }; Outbox: undefined; PhotoSettings: undefined; + Settings: undefined; }; diff --git a/src/screens/InspectionFormScreen.tsx b/src/screens/InspectionFormScreen.tsx index 70501b6..7257dfd 100644 --- a/src/screens/InspectionFormScreen.tsx +++ b/src/screens/InspectionFormScreen.tsx @@ -12,7 +12,10 @@ 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 { RootStackParamList } from '../navigation/types'; +import { MediaStrip } from '../ui/MediaStrip'; import { Card, ChipSelect, @@ -87,6 +90,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(null); + const [inspectionUuid, setInspectionUuid] = useState(null); + useEffect(() => { void getTemplates().then((all) => { // La plantilla sugerida (la de la feature) primero. @@ -102,12 +109,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 +145,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 +153,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; @@ -261,6 +276,17 @@ export function InspectionFormScreen({ route, navigation }: Props) { /> + {inspectionTempId && ( + + Fotos de la inspección + + + )} + void onSubmit()} loading={saving} /> navigation.goBack()} /> @@ -296,4 +322,4 @@ const styles = StyleSheet.create({ tplCardDesc: { fontSize: 13, color: COLORS.muted, marginTop: 2 }, tplCardMeta: { fontSize: 11, color: COLORS.muted, marginTop: 4 }, tplChevron: { fontSize: 24, color: COLORS.muted, marginLeft: 8 }, -}); +}); \ No newline at end of file diff --git a/src/screens/ProjectsScreen.tsx b/src/screens/ProjectsScreen.tsx index e3cae6a..1bc9612 100644 --- a/src/screens/ProjectsScreen.tsx +++ b/src/screens/ProjectsScreen.tsx @@ -1,5 +1,5 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { ActivityIndicator, FlatList, @@ -16,6 +16,7 @@ import { getProjects, saveProjectList, saveTemplates, setActiveProjectId } from import { isOnline } from '../net/connectivity'; import { runSync } from '../sync/engine'; import { RootStackParamList } from '../navigation/types'; +import { COLORS, PrimaryButton } from '../ui/components'; type Props = NativeStackScreenProps; @@ -31,6 +32,8 @@ export function ProjectsScreen({ navigation }: Props) { const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(false); const [opening, setOpening] = useState(null); + const [showMenu, setShowMenu] = useState(false); + const dropdownRef = useRef(null); const load = useCallback(async () => { setLoading(true); @@ -77,36 +80,68 @@ export function ProjectsScreen({ navigation }: Props) { [navigation], ); + const handleLogout = () => { + setShowMenu(false); + signOut(); + }; + + const handleSettings = () => { + setShowMenu(false); + navigation.navigate('Settings'); + }; + return ( + {/* Header with dropdown menu */} - Hola, {user?.name ?? ''} - - Salir + Proyectos + setShowMenu(!showMenu)} + activeOpacity={1} + > + + {user?.name ?? 'Usuario'} + + + {showMenu && ( + + + ⚙ Configuración + + + Salir + + + )} - String(p.id)} - refreshControl={} - ListEmptyComponent={ - loading ? null : No hay proyectos. - } - renderItem={({ item }) => ( - openProject(item)}> - - {item.name} - {item.reference ? {item.reference} : null} - - {opening === item.id ? ( - - ) : ( - - )} - - )} - /> + {/* Close dropdown when tapping outside */} + setShowMenu(false)} style={styles.touchOutside}> + String(p.id)} + refreshControl={} + ListEmptyComponent={loading ? null : No hay proyectos.} + renderItem={({ item }) => ( + openProject(item)} activeOpacity={0.7}> + + {item.name} + {item.reference ? Ref: {item.reference} : null} + {item.address ? 📍 {item.address} : null} + {item.status ? Estado: {item.status} : null} + + {opening === item.id ? ( + + ) : ( + + )} + + )} + /> + ); } @@ -119,18 +154,60 @@ const styles = StyleSheet.create({ alignItems: 'center', padding: 16, }, - hello: { fontSize: 16, fontWeight: '600' }, - logout: { color: '#b00020', fontWeight: '600' }, - row: { + headerTitle: { fontSize: 20, fontWeight: '700', color: COLORS.primary }, + dropdownWrapper: { + position: 'relative', + }, + dropdownLabel: { + fontSize: 16, + fontWeight: '600', + color: '#333', + }, + chevron: { fontSize: 14, color: '#666' }, + dropdownMenu: { + position: 'absolute', + top: 40, + right: 0, + backgroundColor: '#fff', + borderRadius: 8, + borderWidth: 1, + borderColor: COLORS.border, + overflow: 'hidden', + minWidth: 180, + elevation: 4, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + zIndex: 100, + }, + dropdownItem: { + paddingHorizontal: 16, + paddingVertical: 12, + }, + dropdownItemDanger: { + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: COLORS.border, + }, + dropdownItemText: { + fontSize: 15, + color: '#333', + }, + touchOutside: { + flex: 1, + }, + card: { flexDirection: 'row', alignItems: 'center', + justifyContent: 'space-between', paddingHorizontal: 16, - paddingVertical: 14, + paddingVertical: 16, borderTopWidth: StyleSheet.hairlineWidth, - borderColor: '#ddd', + borderColor: COLORS.border, + backgroundColor: '#fff', }, - name: { fontSize: 16, fontWeight: '600' }, - ref: { fontSize: 13, color: '#666', marginTop: 2 }, - chevron: { fontSize: 24, color: '#999' }, + cardContent: { flex: 1 }, + cardName: { fontSize: 16, fontWeight: '600', color: '#111' }, + cardMeta: { fontSize: 13, color: COLORS.muted, marginTop: 4 }, empty: { textAlign: 'center', marginTop: 40, color: '#888' }, }); diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx new file mode 100644 index 0000000..73ff82b --- /dev/null +++ b/src/screens/SettingsScreen.tsx @@ -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 { COLORS, PrimaryButton, SectionTitle } from '../ui/components'; +import { useSession } from '../auth/session'; + +type Nav = NativeStackNavigationProp; + +export function SettingsScreen({ navigation }: NativeStackScreenProps) { + const { user } = useSession(); + const [language, setLanguage] = useState('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 ( + + Configuración de usuario + + + + Idioma de la app + + + handleLanguageChange('en')} + > + English + + handleLanguageChange('es')} + > + Español + + + Por defecto: English + + + + + Configuración de fotos + + + navigation.navigate('PhotoSettings')} + > + + 📷 + Configuración del pie de página + + + + Personaliza logo, campos (proyecto, fecha, coordenadas) y campos personalizados + + + + + Cuenta + + + + Usuario actual + {user?.name ?? 'Desconocido'} + + + Email + {user?.email ?? ''} + + + + + + Acerca de + + + + Versión + 1.0.0 + + + Build + Avante Móvil + + + + ); +} + +const styles = StyleSheet.create({ + body: { padding: 16, gap: 16 }, + card: { + backgroundColor: '#fff', + borderRadius: 10, + padding: 16, + borderWidth: 1, + borderColor: COLORS.border, + }, + row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, + label: { fontSize: 15, fontWeight: '600', color: '#111' }, + hint: { fontSize: 12, color: COLORS.muted, marginTop: 4 }, + languageSelector: { + flexDirection: 'row', + gap: 8, + marginTop: 8, + }, + langBtn: { + flex: 1, + paddingVertical: 10, + paddingHorizontal: 16, + borderRadius: 8, + borderWidth: 1, + borderColor: COLORS.border, + backgroundColor: '#fff', + alignItems: 'center', + }, + langBtnActive: { + backgroundColor: COLORS.primary, + borderColor: COLORS.primary, + }, + langBtnText: { fontSize: 14, color: COLORS.muted }, + langBtnTextActive: { color: '#fff', fontWeight: '600' }, + divider: { height: 1, backgroundColor: COLORS.border, marginVertical: 8 }, + 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: COLORS.muted }, + accountRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 8, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: COLORS.border, + }, + accountLabel: { fontSize: 14, color: COLORS.muted }, + accountValue: { fontSize: 14, fontWeight: '600', color: '#111', textAlign: 'right', flex: 1, marginLeft: 16 }, +}); \ No newline at end of file diff --git a/src/screens/detail/FeatureDetailContent.tsx b/src/screens/detail/FeatureDetailContent.tsx index 412e611..b233b9c 100644 --- a/src/screens/detail/FeatureDetailContent.tsx +++ b/src/screens/detail/FeatureDetailContent.tsx @@ -1,29 +1,18 @@ -/** - * 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 { 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; @@ -52,30 +41,6 @@ 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 ( @@ -87,58 +52,42 @@ export function FeatureDetailContent({ featureId }: { featureId: number }) { const featureType = featureTypes.find((t) => t.id === feature.feature_type_id); const isActive = feature.is_active == null || Number(feature.is_active) !== 0; + // Header right button for Nueva inspección + useFocusEffect( + useCallback(() => { + if (canInspect) { + navigation.setOptions({ + headerRight: () => ( + + navigation.navigate('InspectionForm', { + featureId: feature.id, + featureName: feature.name, + templateId: feature.template_id ?? undefined, + }) + } + > + + Inspección + + ), + }); + } + return () => { + navigation.setOptions({ headerRight: undefined }); + }; + }, [navigation, canInspect, feature?.id, feature?.name, feature?.template_id]), + ); + return ( - {feature.name} {featureType && ( )} - {feature.status && } - {!isActive && } - - - {canProgress && ( - - void onStatus(v)} - /> - Progreso - - {QUICK_PROGRESS.map((p) => { - const active = Math.round(feature.progress ?? 0) === p; - return ( - void onProgress(p)} - > - {p}% - - ); - })} - - - Activa - void onToggleActive(v)} - trackColor={{ true: COLORS.primary }} - /> - - - )} - Inspecciones ({inspections.length}) {inspections.length === 0 && } {inspections.map((ins) => ( @@ -150,19 +99,6 @@ export function FeatureDetailContent({ featureId }: { featureId: number }) { {ins.notes ? {ins.notes} : null} ))} - {canInspect && ( - - navigation.navigate('InspectionForm', { - featureId: feature.id, - featureName: feature.name, - templateId: feature.template_id ?? undefined, - }) - } - /> - )} ); } @@ -170,29 +106,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 }, + headerButton: { + paddingHorizontal: 12, + paddingVertical: 6, + backgroundColor: COLORS.primary, + borderRadius: 6, + marginRight: 8, + }, + headerButtonText: { + color: '#fff', + fontWeight: '600', + fontSize: 13, + }, }); diff --git a/src/sync/mutations.ts b/src/sync/mutations.ts index 9cf4081..ae7c394 100644 --- a/src/sync/mutations.ts +++ b/src/sync/mutations.ts @@ -68,9 +68,11 @@ export async function createInspection(input: { status?: string; result?: string; notes?: string; + uuid?: string; + localId?: number; }): Promise { - 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, diff --git a/src/ui/FeatureMap.tsx b/src/ui/FeatureMap.tsx index ebaecf5..bbce1e3 100644 --- a/src/ui/FeatureMap.tsx +++ b/src/ui/FeatureMap.tsx @@ -70,58 +70,11 @@ export function FeatureMap({ } return ( - - - {shaped.map(({ feature, shapes }) => { - const color = colorFor(feature.status); - const selected = feature.id === selectedId; - const stroke = selected ? '#000' : color; - return ( - - {shapes.points.map((p, i) => ( - onSelect(feature.id)} - /> - ))} - {shapes.lines.map((line, i) => ( - onSelect(feature.id)} - /> - ))} - {shapes.polygons.map((poly, i) => ( - onSelect(feature.id)} - /> - ))} - - ); - })} - - - void recenter()}> - - + + + Mapa desactivado temporalmente para pruebas.{"\n"} + Requiere API Key de Google Maps válida. + ); } diff --git a/src/ui/MediaStrip.tsx b/src/ui/MediaStrip.tsx index e86d2ec..683fe49 100644 --- a/src/ui/MediaStrip.tsx +++ b/src/ui/MediaStrip.tsx @@ -23,7 +23,7 @@ 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 } from '../db/outbox'; import { getActiveProjectId, getMediaFor, getProject } from '../db/repositories'; import { RootStackParamList } from '../navigation/types'; import { @@ -219,6 +219,23 @@ export function MediaStrip({ ]); }, [addPhoto]); + const onDeletePending = useCallback( + async (m: MediaOutboxRow) => { + Alert.alert('Eliminar foto', '¿Deseas eliminar esta foto antes de sincronizar?', [ + { text: 'Cancelar', style: 'cancel' }, + { + text: 'Eliminar', + style: 'destructive', + onPress: async () => { + await deleteMediaOutbox(m.uuid); + await refresh(); + }, + }, + ]); + }, + [refresh], + ); + // ─── render ─────────────────────────────────────────────────────────────── if (synced.length === 0 && pending.length === 0 && !canUpload) return null; @@ -248,7 +265,12 @@ export function MediaStrip({ )} {pending.map((m) => ( - + onDeletePending(m)} + activeOpacity={0.8} + > - + + + + ))} {synced.map((m) => ( @@ -343,4 +368,16 @@ const styles = StyleSheet.create({ alignItems: 'center', }, tagText: { color: '#fff', fontSize: 9, fontWeight: '700' }, + deleteOverlay: { + position: 'absolute', + top: 4, + right: 4, + backgroundColor: 'rgba(0,0,0,0.4)', + width: 20, + height: 20, + borderRadius: 10, + justifyContent: 'center', + alignItems: 'center', + }, + deleteIcon: { color: '#fff', fontSize: 12, fontWeight: 'bold' }, });