Environment fix for Android and UI preparations
This commit is contained in:
@@ -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
|
||||
@@ -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 <task> -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=[]
|
||||
org.gradle.tooling.parallel=true
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Vendored
+17
-57
@@ -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
|
||||
|
||||
@@ -153,6 +153,7 @@ export interface Template {
|
||||
|
||||
export type MediaParentEntity =
|
||||
| 'feature'
|
||||
| 'inspection'
|
||||
| 'issue'
|
||||
| 'issue_task'
|
||||
| 'issue_comment'
|
||||
|
||||
@@ -265,3 +265,8 @@ 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);
|
||||
}
|
||||
|
||||
@@ -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<RootStackParamList>();
|
||||
@@ -74,6 +75,11 @@ export function RootNavigator() {
|
||||
component={PhotoSettingsScreen}
|
||||
options={{ title: 'Configuración de fotos', presentation: 'modal' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Settings"
|
||||
component={SettingsScreen}
|
||||
options={{ title: 'Configuración' }}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
</NavigationContainer>
|
||||
);
|
||||
|
||||
@@ -7,4 +7,5 @@ export type RootStackParamList = {
|
||||
IssueCreate: { projectId: number; featureId?: number };
|
||||
Outbox: undefined;
|
||||
PhotoSettings: undefined;
|
||||
Settings: undefined;
|
||||
};
|
||||
|
||||
@@ -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<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 +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) {
|
||||
/>
|
||||
</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()} />
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
+110
-33
@@ -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<RootStackParamList, 'Projects'>;
|
||||
|
||||
@@ -31,6 +32,8 @@ export function ProjectsScreen({ navigation }: Props) {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [opening, setOpening] = useState<number | null>(null);
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const dropdownRef = useRef<View>(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 (
|
||||
<View style={styles.container}>
|
||||
{/* Header with dropdown menu */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.hello}>Hola, {user?.name ?? ''}</Text>
|
||||
<TouchableOpacity onPress={signOut}>
|
||||
<Text style={styles.logout}>Salir</Text>
|
||||
<Text style={styles.headerTitle}>Proyectos</Text>
|
||||
<TouchableOpacity
|
||||
ref={dropdownRef}
|
||||
style={styles.dropdownWrapper}
|
||||
onPress={() => setShowMenu(!showMenu)}
|
||||
activeOpacity={1}
|
||||
>
|
||||
<Text style={styles.dropdownLabel}>
|
||||
{user?.name ?? 'Usuario'}
|
||||
<Text style={styles.chevron}> ▼</Text>
|
||||
</Text>
|
||||
{showMenu && (
|
||||
<View style={styles.dropdownMenu} pointerEvents="box-none">
|
||||
<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>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={projects}
|
||||
keyExtractor={(p) => String(p.id)}
|
||||
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
|
||||
ListEmptyComponent={
|
||||
loading ? null : <Text style={styles.empty}>No hay proyectos.</Text>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity style={styles.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}
|
||||
</View>
|
||||
{opening === item.id ? (
|
||||
<ActivityIndicator />
|
||||
) : (
|
||||
<Text style={styles.chevron}>›</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
{/* Close dropdown when tapping outside */}
|
||||
<TouchableOpacity onPress={() => setShowMenu(false)} style={styles.touchOutside}>
|
||||
<FlatList
|
||||
data={projects}
|
||||
keyExtractor={(p) => String(p.id)}
|
||||
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
|
||||
ListEmptyComponent={loading ? null : <Text style={styles.empty}>No hay proyectos.</Text>}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity style={styles.card} onPress={() => openProject(item)} activeOpacity={0.7}>
|
||||
<View style={styles.cardContent}>
|
||||
<Text style={styles.cardName}>{item.name}</Text>
|
||||
{item.reference ? <Text style={styles.cardMeta}>Ref: {item.reference}</Text> : null}
|
||||
{item.address ? <Text style={styles.cardMeta}>📍 {item.address}</Text> : null}
|
||||
{item.status ? <Text style={styles.cardMeta}>Estado: {item.status}</Text> : null}
|
||||
</View>
|
||||
{opening === item.id ? (
|
||||
<ActivityIndicator />
|
||||
) : (
|
||||
<Text style={styles.chevron}>›</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
|
||||
@@ -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<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: 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 },
|
||||
});
|
||||
@@ -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<RootStackParamList>;
|
||||
|
||||
@@ -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 (
|
||||
<View style={styles.center}>
|
||||
@@ -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: () => (
|
||||
<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?.id, feature?.name, feature?.template_id]),
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.body}>
|
||||
<Text style={styles.title}>{feature.name}</Text>
|
||||
<View style={styles.badges}>
|
||||
{featureType && (
|
||||
<Badge label={featureType.name} color={featureType.color ?? COLORS.muted} />
|
||||
)}
|
||||
{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} />}
|
||||
</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 +99,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 +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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
+5
-52
@@ -70,58 +70,11 @@ export function FeatureMap({
|
||||
}
|
||||
|
||||
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)}
|
||||
/>
|
||||
))}
|
||||
{shapes.lines.map((line, i) => (
|
||||
<Polyline
|
||||
key={`ln${feature.id}-${i}`}
|
||||
coordinates={line}
|
||||
strokeColor={stroke}
|
||||
strokeWidth={selected ? 5 : 3}
|
||||
tappable
|
||||
onPress={() => onSelect(feature.id)}
|
||||
/>
|
||||
))}
|
||||
{shapes.polygons.map((poly, i) => (
|
||||
<Polygon
|
||||
key={`pg${feature.id}-${i}`}
|
||||
coordinates={poly}
|
||||
strokeColor={stroke}
|
||||
fillColor={`${color}55`}
|
||||
strokeWidth={selected ? 4 : 2}
|
||||
tappable
|
||||
onPress={() => onSelect(feature.id)}
|
||||
/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</MapView>
|
||||
|
||||
<TouchableOpacity style={styles.locBtn} onPress={() => void recenter()}>
|
||||
<Text style={styles.locIcon}>◎</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={[styles.container, { backgroundColor: '#f0f0f0', justifyContent: 'center', alignItems: 'center' }]}>
|
||||
<Text style={{ color: COLORS.muted, textAlign: 'center' }}>
|
||||
Mapa desactivado temporalmente para pruebas.{"\n"}
|
||||
Requiere API Key de Google Maps válida.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
+40
-3
@@ -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({
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{pending.map((m) => (
|
||||
<View key={m.uuid} style={styles.thumbWrap}>
|
||||
<TouchableOpacity
|
||||
key={m.uuid}
|
||||
style={styles.thumbWrap}
|
||||
onPress={() => onDeletePending(m)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Image source={{ uri: m.local_uri }} style={styles.thumb} />
|
||||
<View
|
||||
style={[
|
||||
@@ -260,7 +282,10 @@ export function MediaStrip({
|
||||
{m.status === 'error' ? 'error' : 'en cola'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.deleteOverlay}>
|
||||
<Text style={styles.deleteIcon}>✕</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
{synced.map((m) => (
|
||||
<View key={`s${m.id}`} style={styles.thumbWrap}>
|
||||
@@ -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' },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user