Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 104566ded3 | |||
| b48f7783c9 | |||
| b427fb0f85 |
21
app.json
@@ -1,42 +1,45 @@
|
|||||||
{
|
{
|
||||||
"expo": {
|
"expo": {
|
||||||
"name": "cruiseLovers",
|
"name": "cruise lovers",
|
||||||
"slug": "cruiseLovers",
|
"slug": "cruiseLovers",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/icons/logo.png",
|
||||||
"scheme": "cruiselovers",
|
"scheme": "cruiselovers",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true
|
"supportsTablet": true,
|
||||||
|
"infoPlist": {
|
||||||
|
"LSApplicationQueriesSchemes": ["comgooglemaps", "waze"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"backgroundColor": "#E6F4FE",
|
"backgroundColor": "#E6F4FE",
|
||||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
"foregroundImage": "./assets/icons/logo.png",
|
||||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
"backgroundImage": "./assets/icons/logo.png",
|
||||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
"monochromeImage": "./assets/icons/logo.png"
|
||||||
},
|
},
|
||||||
"edgeToEdgeEnabled": true,
|
"edgeToEdgeEnabled": true,
|
||||||
"predictiveBackGestureEnabled": false
|
"predictiveBackGestureEnabled": false
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"output": "static",
|
"output": "static",
|
||||||
"favicon": "./assets/images/favicon.png"
|
"favicon": "./assets/icons/logo.png"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
"image": "./assets/images/splash-icon.png",
|
"image": "./assets/icons/logo.png",
|
||||||
"imageWidth": 200,
|
"imageWidth": 200,
|
||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#ffffff",
|
"backgroundColor": "#ffffff",
|
||||||
"dark": {
|
"dark": {
|
||||||
"backgroundColor": "#000000"
|
"backgroundColor": "#000000"
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
|
|||||||
5
app/(auth)/_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function AuthLayout() {
|
||||||
|
return <Stack screenOptions={{ headerShown: false }} />;
|
||||||
|
}
|
||||||
110
app/(auth)/login/index.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Image,
|
||||||
|
ImageBackground,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
StatusBar,
|
||||||
|
} from 'react-native';
|
||||||
|
import styles from '@/styles/screens/auth/login.styles';
|
||||||
|
import { useAuth } from '@/assets/contexts/useAuth';
|
||||||
|
import { router, type Href } from 'expo-router';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [localError, setLocalError] = useState<string | null>(null);
|
||||||
|
const { login, isLoading, error } = useAuth();
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
if (!email.trim() || !password.trim()) {
|
||||||
|
setLocalError('Preenche email e palavra-passe.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocalError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(email.trim(), password);
|
||||||
|
router.replace('/home' as Href);
|
||||||
|
} catch {
|
||||||
|
// O erro já é guardado no contexto; evitamos uncaught promise no ecrã.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.container}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 20}>
|
||||||
|
<StatusBar barStyle="light-content" />
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
bounces={false}>
|
||||||
|
<ImageBackground
|
||||||
|
source={require('@/assets/images/banner-login.png')}
|
||||||
|
style={styles.hero}
|
||||||
|
imageStyle={styles.heroImage}>
|
||||||
|
<View style={styles.overlay} />
|
||||||
|
<View style={styles.heroContent}>
|
||||||
|
<Image source={require('@/assets/icons/logotipo-branco.png')} style={styles.logo} resizeMode="contain" />
|
||||||
|
<Text style={styles.title}>Login</Text>
|
||||||
|
<Text style={styles.subtitle}>Acede às tuas reservas, documentos e informaçoes de viagem.</Text>
|
||||||
|
</View>
|
||||||
|
</ImageBackground>
|
||||||
|
|
||||||
|
<View style={styles.formCard}>
|
||||||
|
<Text style={styles.label}>
|
||||||
|
Email<Text style={styles.required}>*</Text>
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
placeholder="hello@domain.pt"
|
||||||
|
placeholderTextColor="#9AA0A6"
|
||||||
|
autoCapitalize="none"
|
||||||
|
keyboardType="email-address"
|
||||||
|
style={styles.input}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>
|
||||||
|
Palavra-passe<Text style={styles.required}>*</Text>
|
||||||
|
</Text>
|
||||||
|
<View style={styles.passwordWrapper}>
|
||||||
|
<TextInput
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
placeholder="Palavra-passe"
|
||||||
|
placeholderTextColor="#9AA0A6"
|
||||||
|
secureTextEntry={!showPassword}
|
||||||
|
style={styles.passwordInput}
|
||||||
|
/>
|
||||||
|
<Pressable onPress={() => setShowPassword((prev) => !prev)} hitSlop={8}>
|
||||||
|
<Ionicons name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={20} style={styles.eyeIcon} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable onPress={() => router.push('/recover' as Href)}>
|
||||||
|
<Text style={styles.forgot}>Esqueci-me da palavra-passe</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{!!(localError || error) && <Text style={styles.errorText}>{localError || error}</Text>}
|
||||||
|
|
||||||
|
<Pressable style={styles.loginButton} onPress={handleLogin} disabled={isLoading}>
|
||||||
|
<Text style={styles.loginButtonText}>Entrar</Text>
|
||||||
|
<Image source={require('@/assets/icons/seta-up.png')} style={styles.loginButtonIcon} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
app/(auth)/recover/confirm.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { RecoverScreenLayout } from '@/assets/components/auth/RecoverScreenLayout';
|
||||||
|
import { LoadingSpinner } from '@/assets/components/LoadingSpinner';
|
||||||
|
import { confirmRecoveryToken } from '@/assets/services/passwordRecovery';
|
||||||
|
import styles from '@/styles/screens/auth/recover.styles';
|
||||||
|
import { router, type Href, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Image, Pressable, Text, TextInput } from 'react-native';
|
||||||
|
|
||||||
|
export default function RecoverConfirmScreen() {
|
||||||
|
const { email, message } = useLocalSearchParams<{ email?: string; message?: string }>();
|
||||||
|
const [token, setToken] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const emailValue = typeof email === 'string' ? email : '';
|
||||||
|
|
||||||
|
const handleTokenChange = (text: string) => {
|
||||||
|
setToken(text.replace(/\D/g, '').slice(0, 6));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!emailValue) {
|
||||||
|
setError('Sessão inválida. Volta ao início do processo.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (token.length !== 6) {
|
||||||
|
setError('Introduz o código de 6 dígitos recebido por email.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await confirmRecoveryToken(emailValue, token);
|
||||||
|
if (data.status === 200) {
|
||||||
|
router.push({
|
||||||
|
pathname: '/recover/reset',
|
||||||
|
params: { email: emailValue, token },
|
||||||
|
} as Href);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(data.message || 'Código inválido ou expirado.');
|
||||||
|
} catch {
|
||||||
|
setError('Falha ao contactar o servidor. Tenta novamente.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!emailValue) {
|
||||||
|
return (
|
||||||
|
<RecoverScreenLayout
|
||||||
|
title="Código de verificação"
|
||||||
|
subtitle="Não foi possível continuar. Volta ao passo anterior.">
|
||||||
|
<Text style={styles.errorText}>Email em falta.</Text>
|
||||||
|
<Pressable style={styles.actionButton} onPress={() => router.replace('/recover' as Href)}>
|
||||||
|
<Text style={styles.actionButtonText}>Voltar</Text>
|
||||||
|
</Pressable>
|
||||||
|
</RecoverScreenLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RecoverScreenLayout
|
||||||
|
title="Código de verificação"
|
||||||
|
subtitle={
|
||||||
|
message
|
||||||
|
? message
|
||||||
|
: `Introduz o código de 6 dígitos enviado para ${emailValue}.`
|
||||||
|
}>
|
||||||
|
<Text style={styles.label}>
|
||||||
|
Código<Text style={styles.required}>*</Text>
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={token}
|
||||||
|
onChangeText={handleTokenChange}
|
||||||
|
placeholder="000000"
|
||||||
|
placeholderTextColor="#9AA0A6"
|
||||||
|
keyboardType="number-pad"
|
||||||
|
maxLength={6}
|
||||||
|
style={[styles.input, { letterSpacing: 8, textAlign: 'center', fontSize: 22 }]}
|
||||||
|
editable={!isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!!error && <Text style={styles.errorText}>{error}</Text>}
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={[styles.actionButton, isLoading && styles.actionButtonDisabled]}
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={isLoading}>
|
||||||
|
{isLoading ? (
|
||||||
|
<LoadingSpinner size="small" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={styles.actionButtonText}>Validar código</Text>
|
||||||
|
<Image source={require('@/assets/icons/seta-up.png')} style={styles.actionButtonIcon} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable onPress={() => router.replace('/recover' as Href)} disabled={isLoading}>
|
||||||
|
<Text style={styles.backLink}>Voltar</Text>
|
||||||
|
</Pressable>
|
||||||
|
</RecoverScreenLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
app/(auth)/recover/index.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { RecoverScreenLayout } from '@/assets/components/auth/RecoverScreenLayout';
|
||||||
|
import { LoadingSpinner } from '@/assets/components/LoadingSpinner';
|
||||||
|
import { recoverPassword } from '@/assets/services/passwordRecovery';
|
||||||
|
import styles from '@/styles/screens/auth/recover.styles';
|
||||||
|
import { router, type Href } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Image, Pressable, Text, TextInput } from 'react-native';
|
||||||
|
|
||||||
|
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
export default function RecoverEmailScreen() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const trimmed = email.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setError('Indica o teu email para continuar.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!EMAIL_REGEX.test(trimmed)) {
|
||||||
|
setError('Introduz um email válido.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await recoverPassword(trimmed);
|
||||||
|
if (data.status === 200) {
|
||||||
|
router.push({
|
||||||
|
pathname: '/recover/confirm',
|
||||||
|
params: { email: trimmed, message: data.message ?? '' },
|
||||||
|
} as Href);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(data.message || 'Não foi possível enviar o código.');
|
||||||
|
} catch {
|
||||||
|
setError('Falha ao contactar o servidor. Tenta novamente.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RecoverScreenLayout
|
||||||
|
title="Repor palavra-passe"
|
||||||
|
subtitle="Indica o teu email para receberes um código de 6 dígitos. O código expira ao fim de 1 hora.">
|
||||||
|
<Text style={styles.label}>
|
||||||
|
Email<Text style={styles.required}>*</Text>
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
placeholder="hello@domain.pt"
|
||||||
|
placeholderTextColor="#9AA0A6"
|
||||||
|
autoCapitalize="none"
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoComplete="email"
|
||||||
|
style={styles.input}
|
||||||
|
editable={!isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!!error && <Text style={styles.errorText}>{error}</Text>}
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={[styles.actionButton, isLoading && styles.actionButtonDisabled]}
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={isLoading}>
|
||||||
|
{isLoading ? (
|
||||||
|
<LoadingSpinner size="small" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={styles.actionButtonText}>Enviar código</Text>
|
||||||
|
<Image source={require('@/assets/icons/seta-up.png')} style={styles.actionButtonIcon} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable onPress={() => router.replace('/login' as Href)} disabled={isLoading}>
|
||||||
|
<Text style={styles.backLink}>Voltar ao Login</Text>
|
||||||
|
</Pressable>
|
||||||
|
</RecoverScreenLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
app/(auth)/recover/reset.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { RecoverScreenLayout } from '@/assets/components/auth/RecoverScreenLayout';
|
||||||
|
import { LoadingSpinner } from '@/assets/components/LoadingSpinner';
|
||||||
|
import { resetPassword } from '@/assets/services/passwordRecovery';
|
||||||
|
import styles from '@/styles/screens/auth/recover.styles';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { router, type Href, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Image, Modal, Pressable, Text, TextInput, View } from 'react-native';
|
||||||
|
|
||||||
|
export default function RecoverResetScreen() {
|
||||||
|
const { email, token } = useLocalSearchParams<{ email?: string; token?: string }>();
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||||
|
const [successMessage, setSuccessMessage] = useState('');
|
||||||
|
|
||||||
|
const emailValue = typeof email === 'string' ? email : '';
|
||||||
|
const tokenValue = typeof token === 'string' ? token : '';
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!emailValue || !tokenValue) {
|
||||||
|
setError('Sessão inválida. Volta ao início do processo.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 5) {
|
||||||
|
setError('A palavra-passe deve ter pelo menos 5 caracteres.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('As palavras-passe não coincidem.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await resetPassword(emailValue, tokenValue, password, confirmPassword);
|
||||||
|
if (data.status === 200) {
|
||||||
|
setSuccessMessage(data.message || 'Password atualizada com sucesso.');
|
||||||
|
setShowSuccessModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(data.message || 'Não foi possível atualizar a palavra-passe.');
|
||||||
|
} catch {
|
||||||
|
setError('Falha ao contactar o servidor. Tenta novamente.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!emailValue || !tokenValue) {
|
||||||
|
return (
|
||||||
|
<RecoverScreenLayout
|
||||||
|
title="Nova palavra-passe"
|
||||||
|
subtitle="Não foi possível continuar. Volta ao passo anterior.">
|
||||||
|
<Text style={styles.errorText}>Dados em falta.</Text>
|
||||||
|
<Pressable style={styles.actionButton} onPress={() => router.replace('/recover' as Href)}>
|
||||||
|
<Text style={styles.actionButtonText}>Voltar</Text>
|
||||||
|
</Pressable>
|
||||||
|
</RecoverScreenLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RecoverScreenLayout
|
||||||
|
title="Nova palavra-passe"
|
||||||
|
subtitle="Define a tua nova palavra-passe.">
|
||||||
|
<Text style={styles.label}>
|
||||||
|
Nova palavra-passe<Text style={styles.required}>*</Text>
|
||||||
|
</Text>
|
||||||
|
<View style={styles.passwordWrapper}>
|
||||||
|
<TextInput
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
placeholder="Palavra-passe"
|
||||||
|
placeholderTextColor="#9AA0A6"
|
||||||
|
secureTextEntry={!showPassword}
|
||||||
|
style={styles.passwordInput}
|
||||||
|
editable={!isLoading}
|
||||||
|
/>
|
||||||
|
<Pressable onPress={() => setShowPassword((p) => !p)} hitSlop={8}>
|
||||||
|
<Ionicons
|
||||||
|
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
|
||||||
|
size={20}
|
||||||
|
style={styles.eyeIcon}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.label}>
|
||||||
|
Confirmar palavra-passe<Text style={styles.required}>*</Text>
|
||||||
|
</Text>
|
||||||
|
<View style={styles.passwordWrapper}>
|
||||||
|
<TextInput
|
||||||
|
value={confirmPassword}
|
||||||
|
onChangeText={setConfirmPassword}
|
||||||
|
placeholder="Confirmar palavra-passe"
|
||||||
|
placeholderTextColor="#9AA0A6"
|
||||||
|
secureTextEntry={!showConfirmPassword}
|
||||||
|
style={styles.passwordInput}
|
||||||
|
editable={!isLoading}
|
||||||
|
/>
|
||||||
|
<Pressable onPress={() => setShowConfirmPassword((p) => !p)} hitSlop={8}>
|
||||||
|
<Ionicons
|
||||||
|
name={showConfirmPassword ? 'eye-off-outline' : 'eye-outline'}
|
||||||
|
size={20}
|
||||||
|
style={styles.eyeIcon}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!!error && <Text style={styles.errorText}>{error}</Text>}
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={[styles.actionButton, isLoading && styles.actionButtonDisabled]}
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={isLoading}>
|
||||||
|
{isLoading ? (
|
||||||
|
<LoadingSpinner size="small" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={styles.actionButtonText}>Guardar palavra-passe</Text>
|
||||||
|
<Image source={require('@/assets/icons/seta-up.png')} style={styles.actionButtonIcon} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={() =>
|
||||||
|
router.replace({
|
||||||
|
pathname: '/recover/confirm',
|
||||||
|
params: { email: emailValue },
|
||||||
|
} as Href)
|
||||||
|
}
|
||||||
|
disabled={isLoading}>
|
||||||
|
<Text style={styles.backLink}>Voltar</Text>
|
||||||
|
</Pressable>
|
||||||
|
</RecoverScreenLayout>
|
||||||
|
|
||||||
|
<Modal visible={showSuccessModal} animationType="fade" transparent>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<View style={styles.modalCard}>
|
||||||
|
<View style={styles.modalIconCircle}>
|
||||||
|
<Ionicons name="checkmark" size={22} style={styles.modalIcon} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.modalTitle}>Palavra-passe atualizada</Text>
|
||||||
|
<Text style={styles.modalMessage}>{successMessage}</Text>
|
||||||
|
<Pressable
|
||||||
|
style={styles.modalCloseButton}
|
||||||
|
onPress={() => {
|
||||||
|
setShowSuccessModal(false);
|
||||||
|
router.replace('/login' as Href);
|
||||||
|
}}>
|
||||||
|
<Text style={styles.modalCloseText}>Ir para o Login</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,35 +1,145 @@
|
|||||||
import { Tabs } from 'expo-router';
|
import { Tabs } from 'expo-router';
|
||||||
import React from 'react';
|
import { Image, Platform, StyleSheet, View } from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { colors } from '@/assets/styles/colors';
|
||||||
|
|
||||||
import { HapticTab } from '@/components/haptic-tab';
|
export default function TabsLayout() {
|
||||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
const insets = useSafeAreaInsets();
|
||||||
import { Colors } from '@/constants/theme';
|
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
|
||||||
|
|
||||||
export default function TabLayout() {
|
|
||||||
const colorScheme = useColorScheme();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
headerShown: true,
|
||||||
headerShown: false,
|
headerTitleAlign: 'center',
|
||||||
tabBarButton: HapticTab,
|
headerShadowVisible: false,
|
||||||
|
headerStyle: {
|
||||||
|
backgroundColor: colors.background_1,
|
||||||
|
},
|
||||||
|
headerTitle: () => (
|
||||||
|
<Image source={require('@/assets/icons/logotipo-azul.png')} style={styles.logo} resizeMode="contain" />
|
||||||
|
),
|
||||||
|
tabBarStyle: [
|
||||||
|
styles.tabBar,
|
||||||
|
{
|
||||||
|
height: (Platform.OS === 'ios' ? 54 : 64) + insets.bottom,
|
||||||
|
paddingBottom: 8 + insets.bottom,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tabBarItemStyle: styles.tabItem,
|
||||||
|
tabBarActiveTintColor: colors.vermelho,
|
||||||
|
tabBarInactiveTintColor: colors.azul,
|
||||||
|
tabBarLabelStyle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
tabBarHideOnKeyboard: true,
|
||||||
}}>
|
}}>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="index"
|
name="perfil/index"
|
||||||
options={{
|
options={{
|
||||||
title: 'Home',
|
title: 'Perfil',
|
||||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
|
tabBarIcon: ({ focused }) => (
|
||||||
|
<View style={focused ? styles.iconActiveContainer : styles.iconContainer}>
|
||||||
|
<Image
|
||||||
|
source={
|
||||||
|
focused
|
||||||
|
? require('@/assets/icons/perfil-selecionado.png')
|
||||||
|
: require('@/assets/icons/perfil.png')
|
||||||
|
}
|
||||||
|
style={focused ? styles.iconActive : styles.icon}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="explore"
|
name="home/index"
|
||||||
options={{
|
options={{
|
||||||
title: 'Explore',
|
title: 'Inicio',
|
||||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
|
tabBarIcon: ({ focused }) => (
|
||||||
|
<View style={focused ? styles.iconActiveContainer : styles.iconContainer}>
|
||||||
|
<Image
|
||||||
|
source={
|
||||||
|
focused
|
||||||
|
? require('@/assets/icons/home-selecionado.png')
|
||||||
|
: require('@/assets/icons/home.png')
|
||||||
|
}
|
||||||
|
style={focused ? styles.iconActive : styles.icon}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="contactos/index"
|
||||||
|
options={{
|
||||||
|
title: 'Contactos',
|
||||||
|
tabBarIcon: ({ focused }) => (
|
||||||
|
<View style={focused ? styles.iconActiveContainer : styles.iconContainer}>
|
||||||
|
<Image
|
||||||
|
source={
|
||||||
|
focused
|
||||||
|
? require('@/assets/icons/contactos-fill.png')
|
||||||
|
: require('@/assets/icons/contactos.png')
|
||||||
|
}
|
||||||
|
style={focused ? styles.iconActive : styles.icon}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
tabBar: {
|
||||||
|
position: 'absolute',
|
||||||
|
paddingHorizontal: 50,
|
||||||
|
paddingTop: 16,
|
||||||
|
backgroundColor: colors.branco,
|
||||||
|
borderTopWidth: 0,
|
||||||
|
borderTopLeftRadius: 60,
|
||||||
|
borderTopRightRadius: 60,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 10,
|
||||||
|
shadowOffset: { width: 0, height: -2 },
|
||||||
|
elevation: 10,
|
||||||
|
},
|
||||||
|
tabItem: {
|
||||||
|
paddingVertical: 1,
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
iconActiveContainer: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 24,
|
||||||
|
backgroundColor: colors.vermelho,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
},
|
||||||
|
iconActive: {
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
tintColor: colors.branco,
|
||||||
|
},
|
||||||
|
logo: {
|
||||||
|
width: 180,
|
||||||
|
height: 60,
|
||||||
|
},
|
||||||
|
});
|
||||||
337
app/(tabs)/contactos/index.tsx
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
import { API_ENDPOINTS, buildApiUrl } from '@/assets/config/api';
|
||||||
|
import { useAuth } from '@/assets/contexts/useAuth';
|
||||||
|
import { cacheContacts, getCachedContacts } from '@/assets/services/offlineStorage';
|
||||||
|
import { colors } from '@/assets/styles/colors';
|
||||||
|
import { ContactData, ContactResponse, SocialMedia } from '@/assets/types';
|
||||||
|
import styles from '@/styles/screens/tabs/contactos.styles';
|
||||||
|
import { FontAwesome } from '@expo/vector-icons';
|
||||||
|
import * as Clipboard from 'expo-clipboard';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { LoadingSpinner } from '@/assets/components/LoadingSpinner';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Image,
|
||||||
|
Linking,
|
||||||
|
Pressable,
|
||||||
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const openPhone = (phone: string) => {
|
||||||
|
const clean = phone.replace(/[^\d+]/g, '');
|
||||||
|
Linking.openURL(`tel:${clean}`).catch(() =>
|
||||||
|
Alert.alert('Erro', 'Não foi possível abrir a aplicação de telefone.')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEmail = (email: string) => {
|
||||||
|
Linking.openURL(`mailto:${email}`).catch(() =>
|
||||||
|
Alert.alert('Erro', 'Não foi possível abrir a aplicação de e-mail.')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openWhatsApp = (value: string) => {
|
||||||
|
const clean = value.replace(/[^\d+]/g, '');
|
||||||
|
const url = `https://wa.me/${clean}`;
|
||||||
|
Linking.openURL(url).catch(() =>
|
||||||
|
Alert.alert('Erro', 'Não foi possível abrir o WhatsApp.')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openMaps = (address: string, lat?: number, lng?: number) => {
|
||||||
|
const url =
|
||||||
|
lat != null && lng != null
|
||||||
|
? `maps://?ll=${lat},${lng}&q=${encodeURIComponent(address)}`
|
||||||
|
: `maps:?q=${encodeURIComponent(address)}`;
|
||||||
|
|
||||||
|
Linking.openURL(url).catch(() =>
|
||||||
|
Linking.openURL(
|
||||||
|
lat != null && lng != null
|
||||||
|
? `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`
|
||||||
|
: `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`
|
||||||
|
).catch(() => Alert.alert('Erro', 'Não foi possível abrir a aplicação de mapas.'))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async (value: string, label: string) => {
|
||||||
|
await Clipboard.setStringAsync(value);
|
||||||
|
Alert.alert('Copiado', `${label} copiado para a área de transferência.`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractPhone = (url: string): string => {
|
||||||
|
const match = url.match(/(?:phone=|wa\.me\/)([\d+]+)/);
|
||||||
|
if (match) return `+${match[1].replace(/^\+/, '')}`;
|
||||||
|
const clean = url.replace(/[^\d+]/g, '');
|
||||||
|
return clean.startsWith('+') ? clean : `+${clean}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseCoords = (raw?: string | null): { lat: number; lng: number } | null => {
|
||||||
|
if (!raw) return null;
|
||||||
|
const parts = raw.split(',').map((s) => parseFloat(s.trim()));
|
||||||
|
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
|
||||||
|
return { lat: parts[0], lng: parts[1] };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const parseHorarios = (raw: string): { dia: string; horas: string }[] => {
|
||||||
|
return raw
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => {
|
||||||
|
const sep = line.lastIndexOf(':');
|
||||||
|
if (sep > 0) {
|
||||||
|
return { dia: line.slice(0, sep).trim(), horas: line.slice(sep + 1).trim() };
|
||||||
|
}
|
||||||
|
const parts = line.split(/\s{2,}|\t/);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return { dia: parts[0].trim(), horas: parts.slice(1).join(' ').trim() };
|
||||||
|
}
|
||||||
|
return { dia: line, horas: '' };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const SOCIAL_ICONS: Record<string, string> = {
|
||||||
|
facebook: 'facebook',
|
||||||
|
instagram: 'instagram',
|
||||||
|
tiktok: 'music', // FontAwesome doesn't have tiktok
|
||||||
|
twitter: 'twitter',
|
||||||
|
linkedin: 'linkedin',
|
||||||
|
youtube: 'youtube-play',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── screen ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function Contactos() {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const [contactData, setContactData] = useState<ContactData | null>(null);
|
||||||
|
const [socials, setSocials] = useState<SocialMedia[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [mapLoading, setMapLoading] = useState(true);
|
||||||
|
const [mapError, setMapError] = useState(false);
|
||||||
|
|
||||||
|
const fetchContacts = async () => {
|
||||||
|
// 1. Mostrar cache imediatamente enquanto atualiza em fundo
|
||||||
|
const cached = await getCachedContacts();
|
||||||
|
if (cached.contactData) {
|
||||||
|
setContactData(cached.contactData);
|
||||||
|
setSocials(cached.socials);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
if (token) formData.append('token', token);
|
||||||
|
|
||||||
|
const response = await fetch(buildApiUrl(API_ENDPOINTS.CONTACTS), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: ContactResponse = await response.json();
|
||||||
|
|
||||||
|
console.log('data', data);
|
||||||
|
|
||||||
|
if (data.status === 200 && data.contact) {
|
||||||
|
const validSocials = (data.socials || []).filter((s) => s.value?.trim());
|
||||||
|
setContactData(data.contact);
|
||||||
|
setSocials(validSocials);
|
||||||
|
setError(null);
|
||||||
|
await cacheContacts(data.contact, validSocials);
|
||||||
|
} else if (!cached.contactData) {
|
||||||
|
setError(data.message || 'Não foi possível carregar os contactos.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cached.contactData) {
|
||||||
|
setError('Sem ligação à internet e sem dados guardados.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchContacts();
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const onRefresh = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await fetchContacts();
|
||||||
|
setRefreshing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.centered}>
|
||||||
|
<LoadingSpinner size="large" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !contactData) {
|
||||||
|
return (
|
||||||
|
<View style={styles.centered}>
|
||||||
|
<Text style={styles.errorText}>{error}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const horarios = contactData?.horarios ? parseHorarios(contactData.horarios) : [];
|
||||||
|
const coords = parseCoords(contactData?.coordenadas);
|
||||||
|
const mapUrl = coords
|
||||||
|
? `https://staticmap.openstreetmap.de/staticmap.php?center=${coords.lat},${coords.lng}&zoom=15&size=600x300&markers=${coords.lat},${coords.lng},red-pushpin`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.content}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
|
||||||
|
|
||||||
|
<Text style={styles.pageTitle}>Contactos Agência</Text>
|
||||||
|
|
||||||
|
{/* ── Ações rápidas ── */}
|
||||||
|
<View style={styles.actionsRow}>
|
||||||
|
{!!contactData?.mobilePhone && (
|
||||||
|
<Pressable
|
||||||
|
style={styles.actionBtn}
|
||||||
|
onPress={() => openPhone(contactData.telephone)}>
|
||||||
|
<View style={styles.actionIconWrap}>
|
||||||
|
<Image source={require('@/assets/icons/telefone.png')} style={styles.actionIcon} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.actionLabel}>Ligar Agora</Text>
|
||||||
|
<Text style={styles.actionSub} numberOfLines={1}>
|
||||||
|
{contactData.telephone}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!!contactData?.email && (
|
||||||
|
<Pressable
|
||||||
|
style={styles.actionBtn}
|
||||||
|
onPress={() => openEmail(contactData.email)}>
|
||||||
|
<View style={styles.actionIconWrap}>
|
||||||
|
<Image source={require('@/assets/icons/email.png')} style={styles.actionIcon} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.actionLabel}>Enviar Email</Text>
|
||||||
|
<Text style={styles.actionSub} numberOfLines={1}>
|
||||||
|
{contactData.email}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!!contactData?.mobilePhone && (
|
||||||
|
<Pressable
|
||||||
|
style={styles.actionBtn}
|
||||||
|
onPress={() => openWhatsApp(contactData.mobilePhone!)}>
|
||||||
|
<View style={styles.actionIconWrap}>
|
||||||
|
<Image source={require('@/assets/icons/whatsapp.png')} style={styles.actionIcon} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.actionLabel}>Falar no Chat</Text>
|
||||||
|
<Text style={styles.actionSub} numberOfLines={1}>
|
||||||
|
{extractPhone(contactData.mobilePhone)}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Localização ── */}
|
||||||
|
{!!contactData?.address && (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>Localização</Text>
|
||||||
|
|
||||||
|
{!!mapUrl && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => openMaps(contactData.address, coords?.lat, coords?.lng)}
|
||||||
|
style={styles.mapImageWrap}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: mapUrl }}
|
||||||
|
style={styles.mapImage}
|
||||||
|
resizeMode="cover"
|
||||||
|
onLoadStart={() => { setMapLoading(true); setMapError(false); }}
|
||||||
|
onLoad={() => setMapLoading(false)}
|
||||||
|
onError={() => { setMapLoading(false); setMapError(true); }}
|
||||||
|
/>
|
||||||
|
{mapLoading && !mapError && (
|
||||||
|
<View style={styles.mapOverlay}>
|
||||||
|
<LoadingSpinner size="small" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{mapError && (
|
||||||
|
<View style={styles.mapOverlay}>
|
||||||
|
<FontAwesome name="map-marker" size={28} color={colors.vermelho} />
|
||||||
|
<Text style={styles.mapErrorText}>Mapa indisponível</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.addressRow}>
|
||||||
|
<FontAwesome name="map-marker" size={16} color={colors.vermelho} />
|
||||||
|
<Text style={styles.addressText}>{contactData.address}</Text>
|
||||||
|
<Pressable onPress={() => copyToClipboard(contactData.address, 'Morada')}>
|
||||||
|
<FontAwesome name="copy" size={14} color={colors.azul} style={styles.copyIcon} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
style={styles.direcoeBtn}
|
||||||
|
onPress={() => openMaps(contactData.address, coords?.lat, coords?.lng)}>
|
||||||
|
<Text style={styles.direcoeBtnText}>Obter Direções</Text>
|
||||||
|
<Image source={require('@/assets/icons/seta-up.png')} style={styles.saveButtonIcon} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Horário de Funcionamento ── */}
|
||||||
|
{horarios.length > 0 && (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>Horário de Funcionamento</Text>
|
||||||
|
{horarios.map((item, idx) => (
|
||||||
|
<View
|
||||||
|
key={idx}
|
||||||
|
style={[styles.horarioRow, idx < horarios.length - 1 && styles.horarioRowBorder]}>
|
||||||
|
<Text style={styles.horarioDia}>{item.dia}</Text>
|
||||||
|
<Text style={[
|
||||||
|
styles.horarioHoras,
|
||||||
|
item.horas.toLowerCase() === 'fechado' && styles.horarioFechado,
|
||||||
|
]}>
|
||||||
|
{item.horas || '—'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Redes Sociais ── */}
|
||||||
|
{socials.length > 0 && (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>Redes Sociais</Text>
|
||||||
|
<View style={styles.socialsRow}>
|
||||||
|
{socials.map((social, idx) => (
|
||||||
|
<Pressable
|
||||||
|
key={idx}
|
||||||
|
style={styles.socialBtn}
|
||||||
|
onPress={() => Linking.openURL(social.value).catch(() => null)}>
|
||||||
|
<FontAwesome
|
||||||
|
name={(SOCIAL_ICONS[social.key.toLowerCase()] ?? 'globe') as any}
|
||||||
|
size={20}
|
||||||
|
color={colors.azul}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import { Image } from 'expo-image';
|
|
||||||
import { Platform, StyleSheet } from 'react-native';
|
|
||||||
|
|
||||||
import { Collapsible } from '@/components/ui/collapsible';
|
|
||||||
import { ExternalLink } from '@/components/external-link';
|
|
||||||
import ParallaxScrollView from '@/components/parallax-scroll-view';
|
|
||||||
import { ThemedText } from '@/components/themed-text';
|
|
||||||
import { ThemedView } from '@/components/themed-view';
|
|
||||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
|
||||||
import { Fonts } from '@/constants/theme';
|
|
||||||
|
|
||||||
export default function TabTwoScreen() {
|
|
||||||
return (
|
|
||||||
<ParallaxScrollView
|
|
||||||
headerBackgroundColor={{ light: '#D0D0D0', dark: '#353636' }}
|
|
||||||
headerImage={
|
|
||||||
<IconSymbol
|
|
||||||
size={310}
|
|
||||||
color="#808080"
|
|
||||||
name="chevron.left.forwardslash.chevron.right"
|
|
||||||
style={styles.headerImage}
|
|
||||||
/>
|
|
||||||
}>
|
|
||||||
<ThemedView style={styles.titleContainer}>
|
|
||||||
<ThemedText
|
|
||||||
type="title"
|
|
||||||
style={{
|
|
||||||
fontFamily: Fonts.rounded,
|
|
||||||
}}>
|
|
||||||
Explore
|
|
||||||
</ThemedText>
|
|
||||||
</ThemedView>
|
|
||||||
<ThemedText>This app includes example code to help you get started.</ThemedText>
|
|
||||||
<Collapsible title="File-based routing">
|
|
||||||
<ThemedText>
|
|
||||||
This app has two screens:{' '}
|
|
||||||
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '}
|
|
||||||
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
|
|
||||||
</ThemedText>
|
|
||||||
<ThemedText>
|
|
||||||
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '}
|
|
||||||
sets up the tab navigator.
|
|
||||||
</ThemedText>
|
|
||||||
<ExternalLink href="https://docs.expo.dev/router/introduction">
|
|
||||||
<ThemedText type="link">Learn more</ThemedText>
|
|
||||||
</ExternalLink>
|
|
||||||
</Collapsible>
|
|
||||||
<Collapsible title="Android, iOS, and web support">
|
|
||||||
<ThemedText>
|
|
||||||
You can open this project on Android, iOS, and the web. To open the web version, press{' '}
|
|
||||||
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
|
|
||||||
</ThemedText>
|
|
||||||
</Collapsible>
|
|
||||||
<Collapsible title="Images">
|
|
||||||
<ThemedText>
|
|
||||||
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '}
|
|
||||||
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
|
|
||||||
different screen densities
|
|
||||||
</ThemedText>
|
|
||||||
<Image
|
|
||||||
source={require('@/assets/images/react-logo.png')}
|
|
||||||
style={{ width: 100, height: 100, alignSelf: 'center' }}
|
|
||||||
/>
|
|
||||||
<ExternalLink href="https://reactnative.dev/docs/images">
|
|
||||||
<ThemedText type="link">Learn more</ThemedText>
|
|
||||||
</ExternalLink>
|
|
||||||
</Collapsible>
|
|
||||||
<Collapsible title="Light and dark mode components">
|
|
||||||
<ThemedText>
|
|
||||||
This template has light and dark mode support. The{' '}
|
|
||||||
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect
|
|
||||||
what the user's current color scheme is, and so you can adjust UI colors accordingly.
|
|
||||||
</ThemedText>
|
|
||||||
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
|
|
||||||
<ThemedText type="link">Learn more</ThemedText>
|
|
||||||
</ExternalLink>
|
|
||||||
</Collapsible>
|
|
||||||
<Collapsible title="Animations">
|
|
||||||
<ThemedText>
|
|
||||||
This template includes an example of an animated component. The{' '}
|
|
||||||
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
|
|
||||||
the powerful{' '}
|
|
||||||
<ThemedText type="defaultSemiBold" style={{ fontFamily: Fonts.mono }}>
|
|
||||||
react-native-reanimated
|
|
||||||
</ThemedText>{' '}
|
|
||||||
library to create a waving hand animation.
|
|
||||||
</ThemedText>
|
|
||||||
{Platform.select({
|
|
||||||
ios: (
|
|
||||||
<ThemedText>
|
|
||||||
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '}
|
|
||||||
component provides a parallax effect for the header image.
|
|
||||||
</ThemedText>
|
|
||||||
),
|
|
||||||
})}
|
|
||||||
</Collapsible>
|
|
||||||
</ParallaxScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
headerImage: {
|
|
||||||
color: '#808080',
|
|
||||||
bottom: -90,
|
|
||||||
left: -35,
|
|
||||||
position: 'absolute',
|
|
||||||
},
|
|
||||||
titleContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
337
app/(tabs)/home/index.tsx
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
import { colors } from '@/assets/styles/colors';
|
||||||
|
import { useAuth } from '@/assets/contexts/useAuth';
|
||||||
|
import { API_ENDPOINTS, buildApiUrl } from '@/assets/config/api';
|
||||||
|
import { cacheReservas, getCachedReservas } from '@/assets/services/offlineStorage';
|
||||||
|
import { downloadDocumentos, getDocumentosChecksums, verificarDocumentosParaDownload } from '@/assets/services/documentSync';
|
||||||
|
import { Reserva, ReservasResponse } from '@/assets/types';
|
||||||
|
import { Href, router } from 'expo-router';
|
||||||
|
import styles from '@/styles/screens/tabs/home.styles';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { LoadingSpinner } from '@/assets/components/LoadingSpinner';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Image,
|
||||||
|
Pressable,
|
||||||
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { FontAwesome } from '@expo/vector-icons';
|
||||||
|
|
||||||
|
const getStatusPriority = (statusCode: string): number => {
|
||||||
|
switch (statusCode) {
|
||||||
|
case '10':
|
||||||
|
return 1; // Em viagem
|
||||||
|
case '1':
|
||||||
|
case '5':
|
||||||
|
return 2; // Confirmada
|
||||||
|
case '-1':
|
||||||
|
return 3; // Em pagamento
|
||||||
|
case '-2':
|
||||||
|
case '-3':
|
||||||
|
return 4; // Pendente
|
||||||
|
case '99':
|
||||||
|
return 5; // Viagem realizada
|
||||||
|
case '-5':
|
||||||
|
return 7; // Cancelada — sempre no fim
|
||||||
|
default:
|
||||||
|
return 6;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const [reservas, setReservas] = useState<Reserva[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isFromCache, setIsFromCache] = useState(false);
|
||||||
|
|
||||||
|
const syncDocumentos = async () => {
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
const checksumsData = await getDocumentosChecksums(token);
|
||||||
|
if (!checksumsData || checksumsData.status !== 200 || !checksumsData.documentos?.length) return;
|
||||||
|
|
||||||
|
const documentosParaDownload = await verificarDocumentosParaDownload(checksumsData.documentos);
|
||||||
|
if (!documentosParaDownload.length) return;
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
'Documentos disponíveis',
|
||||||
|
`Existem ${documentosParaDownload.length} ficheiro${documentosParaDownload.length === 1 ? '' : 's'} novo${documentosParaDownload.length === 1 ? '' : 's'} para descarregar/atualizar. Pretende descarregá-los agora?`,
|
||||||
|
[
|
||||||
|
{ text: 'Não', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Sim',
|
||||||
|
onPress: () => downloadDocumentos(documentosParaDownload),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// sync de documentos é silencioso — não bloqueia o utilizador
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchReservas = async () => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
setReservas([]);
|
||||||
|
setIsFromCache(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = buildApiUrl(API_ENDPOINTS.USER_RESERVAS);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('token', token);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: ReservasResponse = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || (data.status !== 200 && data.status !== '200')) {
|
||||||
|
throw new Error(data.message || 'Erro ao carregar reservas');
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedReservas = [...(data.reservas || [])].sort((a, b) => {
|
||||||
|
const priorityDiff =
|
||||||
|
getStatusPriority(a.statusCode) - getStatusPriority(b.statusCode);
|
||||||
|
if (priorityDiff !== 0) return priorityDiff;
|
||||||
|
|
||||||
|
return new Date(a.startDate).getTime() - new Date(b.startDate).getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
setReservas(orderedReservas);
|
||||||
|
setIsFromCache(false);
|
||||||
|
await cacheReservas(orderedReservas);
|
||||||
|
syncDocumentos();
|
||||||
|
} catch {
|
||||||
|
const cached = await getCachedReservas();
|
||||||
|
if (cached && cached.length > 0) {
|
||||||
|
setReservas(cached);
|
||||||
|
setIsFromCache(true);
|
||||||
|
setError(null);
|
||||||
|
} else {
|
||||||
|
setError('Sem ligação à internet e sem dados guardados.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setReservas([]);
|
||||||
|
setIsFromCache(false);
|
||||||
|
setIsLoading(true);
|
||||||
|
fetchReservas();
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const onRefresh = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await fetchReservas();
|
||||||
|
setRefreshing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const proximas = reservas.filter((reserva) => new Date(reserva.startDate) >= now);
|
||||||
|
const total = reservas.length;
|
||||||
|
const confirmadas = reservas.filter((reserva) => reserva.statusCode === '1').length;
|
||||||
|
const proxima = proximas[0];
|
||||||
|
|
||||||
|
let dias = '--';
|
||||||
|
if (proxima) {
|
||||||
|
const diff = new Date(proxima.startDate).getTime() - now.getTime();
|
||||||
|
dias = `${Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24)))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reservaEmViagem = reservas.find((reserva) => reserva.statusCode === '10');
|
||||||
|
|
||||||
|
return {
|
||||||
|
dias,
|
||||||
|
total,
|
||||||
|
confirmadas,
|
||||||
|
emViagem: Boolean(reservaEmViagem),
|
||||||
|
destinoEmViagem: reservaEmViagem?.destino?.trim() || '—',
|
||||||
|
};
|
||||||
|
}, [reservas]);
|
||||||
|
|
||||||
|
const getStatusType = (statusCode: string) => {
|
||||||
|
if (statusCode === '1' || statusCode === '5') return 'verde';
|
||||||
|
if (statusCode === '-5') return 'vermelho';
|
||||||
|
if (statusCode === '10' || statusCode === '99') return 'roxo';
|
||||||
|
if (statusCode === '-2' || statusCode === '-1' || statusCode === '-3') return 'amarelo';
|
||||||
|
return 'neutral';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (statusType: string) => {
|
||||||
|
switch (statusType) {
|
||||||
|
case 'verde':
|
||||||
|
return '#13AE45';
|
||||||
|
case 'vermelho':
|
||||||
|
return '#E6463B';
|
||||||
|
case 'roxo':
|
||||||
|
return '#B138E6';
|
||||||
|
case 'amarelo':
|
||||||
|
return '#C99700';
|
||||||
|
default:
|
||||||
|
return '#4A6592';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusConfig = (statusCode: string) => {
|
||||||
|
switch (statusCode) {
|
||||||
|
case '1':
|
||||||
|
return { label: 'Confirmada', icon: 'check-circle' as const };
|
||||||
|
case '5':
|
||||||
|
return { label: 'Confirmada*', icon: 'check-circle' as const };
|
||||||
|
case '-5':
|
||||||
|
return { label: 'Cancelada', icon: 'times-circle' as const };
|
||||||
|
case '10':
|
||||||
|
return { label: 'Em Viagem', icon: 'ship' as const };
|
||||||
|
case '99':
|
||||||
|
return { label: 'Viagem Realizada', icon: 'ship' as const };
|
||||||
|
case '-2':
|
||||||
|
case '-3':
|
||||||
|
return { label: 'Pendente', icon: 'clock-o' as const };
|
||||||
|
case '-1':
|
||||||
|
return { label: 'Em pagamento', icon: 'clock-o' as const };
|
||||||
|
default:
|
||||||
|
return { label: 'Não definido', icon: 'question-circle' as const };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.centered}>
|
||||||
|
<LoadingSpinner size="large" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(reservas);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.content}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
|
||||||
|
|
||||||
|
<View style={styles.statsRow}>
|
||||||
|
<View style={[styles.statCard, styles.statCardPrimary]}>
|
||||||
|
{stats.emViagem ? (
|
||||||
|
<View style={styles.statIconEmViagemWrap}>
|
||||||
|
<FontAwesome name="ship" size={20} color={colors.azul} />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Image source={require('@/assets/icons/mala.png')} style={styles.statIcon} />
|
||||||
|
)}
|
||||||
|
<Text style={styles.statLabelPrimary}>
|
||||||
|
{stats.emViagem ? 'Em viagem' : 'Proxima Viagem'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.statValuePrimary} numberOfLines={stats.emViagem ? 2 : 1}>
|
||||||
|
{stats.emViagem ? stats.destinoEmViagem : `${stats.dias} dias`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.statCard}>
|
||||||
|
<Image source={require('@/assets/icons/calendario.png')} style={styles.statIcon} />
|
||||||
|
<Text style={styles.statLabel}>Total Reservas</Text>
|
||||||
|
<Text style={styles.statValue}>{stats.total}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.statCard}>
|
||||||
|
<Image source={require('@/assets/icons/confirmadas.png')} style={styles.statIcon} />
|
||||||
|
<Text style={styles.statLabel}>Confirmadas</Text>
|
||||||
|
<Text style={styles.statValue}>{stats.confirmadas}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isFromCache && (
|
||||||
|
<View style={styles.offlineBanner}>
|
||||||
|
<FontAwesome name="wifi" size={13} color={colors.branco} />
|
||||||
|
<Text style={styles.offlineBannerText}>Sem ligação — a mostrar dados guardados</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={styles.sectionTitle}>As minhas Reservas</Text>
|
||||||
|
|
||||||
|
{!!error && <Text style={styles.errorText}>{error}</Text>}
|
||||||
|
|
||||||
|
{!error && reservas.length === 0 && (
|
||||||
|
<Text style={styles.emptyText}>Sem reservas para apresentar.</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error &&
|
||||||
|
reservas.map((reserva, index) => {
|
||||||
|
const statusType = getStatusType(reserva.statusCode);
|
||||||
|
const statusConfig = getStatusConfig(reserva.statusCode);
|
||||||
|
const statusColor = getStatusColor(statusType);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={`${reserva.referenciaViagem}-${index}`}
|
||||||
|
style={styles.card}
|
||||||
|
onPress={() =>
|
||||||
|
router.push(`/reserva/${encodeURIComponent(reserva.referenciaViagem || '')}` as Href)
|
||||||
|
}>
|
||||||
|
<Image
|
||||||
|
source={
|
||||||
|
reserva.imagemCidade
|
||||||
|
? { uri: reserva.imagemCidade }
|
||||||
|
: require('@/assets/icons/logo.png')
|
||||||
|
}
|
||||||
|
style={styles.cardImage}
|
||||||
|
/>
|
||||||
|
<View style={styles.cardContent}>
|
||||||
|
<View style={styles.cardTopRow}>
|
||||||
|
<Text style={styles.refText}>{reserva.referenciaViagem || '---'}</Text>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusBadge,
|
||||||
|
statusType === 'verde' && styles.statusSuccess,
|
||||||
|
statusType === 'vermelho' && styles.statusDanger,
|
||||||
|
statusType === 'roxo' && styles.statusInfo,
|
||||||
|
statusType === 'amarelo' && styles.statusPendente,
|
||||||
|
]}>
|
||||||
|
<View style={styles.statusBadgeContent}>
|
||||||
|
<FontAwesome name={statusConfig.icon} size={12} color={statusColor} />
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.statusText,
|
||||||
|
statusType === 'verde' && styles.statusTextSuccess,
|
||||||
|
statusType === 'vermelho' && styles.statusTextDanger,
|
||||||
|
statusType === 'roxo' && styles.statusTextInfo,
|
||||||
|
statusType === 'amarelo' && styles.statusTextPendente,
|
||||||
|
]}>
|
||||||
|
{statusConfig.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.destination}>{reserva.destino}</Text>
|
||||||
|
<Text style={styles.meta}><FontAwesome name="map" size={12} color="#4A6592" /> {reserva.pais}</Text>
|
||||||
|
<View style={styles.dateRow}>
|
||||||
|
<FontAwesome name="calendar" size={12} color="#4A6592" />
|
||||||
|
<Text style={styles.meta}>{reserva.startDate}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.arrowWrap}>
|
||||||
|
<FontAwesome name="angle-right" size={20} color="#EB2415" />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import { Image } from 'expo-image';
|
|
||||||
import { Platform, StyleSheet } from 'react-native';
|
|
||||||
|
|
||||||
import { HelloWave } from '@/components/hello-wave';
|
|
||||||
import ParallaxScrollView from '@/components/parallax-scroll-view';
|
|
||||||
import { ThemedText } from '@/components/themed-text';
|
|
||||||
import { ThemedView } from '@/components/themed-view';
|
|
||||||
import { Link } from 'expo-router';
|
|
||||||
|
|
||||||
export default function HomeScreen() {
|
|
||||||
return (
|
|
||||||
<ParallaxScrollView
|
|
||||||
headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }}
|
|
||||||
headerImage={
|
|
||||||
<Image
|
|
||||||
source={require('@/assets/images/partial-react-logo.png')}
|
|
||||||
style={styles.reactLogo}
|
|
||||||
/>
|
|
||||||
}>
|
|
||||||
<ThemedView style={styles.titleContainer}>
|
|
||||||
<ThemedText type="title">Welcome!</ThemedText>
|
|
||||||
<HelloWave />
|
|
||||||
</ThemedView>
|
|
||||||
<ThemedView style={styles.stepContainer}>
|
|
||||||
<ThemedText type="subtitle">Step 1: Try it</ThemedText>
|
|
||||||
<ThemedText>
|
|
||||||
Edit <ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> to see changes.
|
|
||||||
Press{' '}
|
|
||||||
<ThemedText type="defaultSemiBold">
|
|
||||||
{Platform.select({
|
|
||||||
ios: 'cmd + d',
|
|
||||||
android: 'cmd + m',
|
|
||||||
web: 'F12',
|
|
||||||
})}
|
|
||||||
</ThemedText>{' '}
|
|
||||||
to open developer tools.
|
|
||||||
</ThemedText>
|
|
||||||
</ThemedView>
|
|
||||||
<ThemedView style={styles.stepContainer}>
|
|
||||||
<Link href="/modal">
|
|
||||||
<Link.Trigger>
|
|
||||||
<ThemedText type="subtitle">Step 2: Explore</ThemedText>
|
|
||||||
</Link.Trigger>
|
|
||||||
<Link.Preview />
|
|
||||||
<Link.Menu>
|
|
||||||
<Link.MenuAction title="Action" icon="cube" onPress={() => alert('Action pressed')} />
|
|
||||||
<Link.MenuAction
|
|
||||||
title="Share"
|
|
||||||
icon="square.and.arrow.up"
|
|
||||||
onPress={() => alert('Share pressed')}
|
|
||||||
/>
|
|
||||||
<Link.Menu title="More" icon="ellipsis">
|
|
||||||
<Link.MenuAction
|
|
||||||
title="Delete"
|
|
||||||
icon="trash"
|
|
||||||
destructive
|
|
||||||
onPress={() => alert('Delete pressed')}
|
|
||||||
/>
|
|
||||||
</Link.Menu>
|
|
||||||
</Link.Menu>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<ThemedText>
|
|
||||||
{`Tap the Explore tab to learn more about what's included in this starter app.`}
|
|
||||||
</ThemedText>
|
|
||||||
</ThemedView>
|
|
||||||
<ThemedView style={styles.stepContainer}>
|
|
||||||
<ThemedText type="subtitle">Step 3: Get a fresh start</ThemedText>
|
|
||||||
<ThemedText>
|
|
||||||
{`When you're ready, run `}
|
|
||||||
<ThemedText type="defaultSemiBold">npm run reset-project</ThemedText> to get a fresh{' '}
|
|
||||||
<ThemedText type="defaultSemiBold">app</ThemedText> directory. This will move the current{' '}
|
|
||||||
<ThemedText type="defaultSemiBold">app</ThemedText> to{' '}
|
|
||||||
<ThemedText type="defaultSemiBold">app-example</ThemedText>.
|
|
||||||
</ThemedText>
|
|
||||||
</ThemedView>
|
|
||||||
</ParallaxScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
titleContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
stepContainer: {
|
|
||||||
gap: 8,
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
reactLogo: {
|
|
||||||
height: 178,
|
|
||||||
width: 290,
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
position: 'absolute',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
394
app/(tabs)/perfil/index.tsx
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
import { API_ENDPOINTS, buildApiUrl } from '@/assets/config/api';
|
||||||
|
import { useAuth } from '@/assets/contexts/useAuth';
|
||||||
|
import ChevronRightIcon from '@/assets/icons/chevron-right.svg';
|
||||||
|
import UserDeleteIcon from '@/assets/icons/user-xmark-solid-full.svg';
|
||||||
|
import LogoutIcon from '@/assets/icons/right-from-bracket-solid-full.svg';
|
||||||
|
import UserIcon from '@/assets/icons/user-solid-full.svg';
|
||||||
|
import { colors } from '@/assets/styles/colors';
|
||||||
|
import { UserInfoResponse } from '@/assets/types';
|
||||||
|
import styles from '@/styles/screens/tabs/perfil.styles';
|
||||||
|
import { type Href, router } from 'expo-router';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { LoadingSpinner } from '@/assets/components/LoadingSpinner';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Image,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Modal,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import Animated, {
|
||||||
|
Easing,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
const ANIM_DURATION = 280;
|
||||||
|
|
||||||
|
const formatDisplayName = (nome?: string, apelido?: string) => {
|
||||||
|
const parts = [nome?.trim(), apelido?.trim()].filter((p): p is string => !!p);
|
||||||
|
return parts.length > 0 ? parts.join(' ') : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Perfil() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { token, user: authUser, logout, updateProfile, isLoading } = useAuth();
|
||||||
|
const [userInfo, setUserInfo] = useState<UserInfoResponse | null>(null);
|
||||||
|
const [isLoadingProfile, setIsLoadingProfile] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [isEditingOpen, setIsEditingOpen] = useState(false);
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [isDeletingAccount, setIsDeletingAccount] = useState(false);
|
||||||
|
const [confirmModal, setConfirmModal] = useState<null | 'logout' | 'delete'>(null);
|
||||||
|
|
||||||
|
const progress = useSharedValue(0);
|
||||||
|
const contentHeight = useSharedValue(0);
|
||||||
|
const [measuredHeight, setMeasuredHeight] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (measuredHeight > 0) {
|
||||||
|
contentHeight.value = measuredHeight;
|
||||||
|
}
|
||||||
|
}, [measuredHeight, contentHeight]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
progress.value = withTiming(isEditingOpen ? 1 : 0, {
|
||||||
|
duration: ANIM_DURATION,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
});
|
||||||
|
}, [isEditingOpen, progress]);
|
||||||
|
|
||||||
|
const editBodyAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
height: contentHeight.value * progress.value,
|
||||||
|
opacity: progress.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const chevronAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ rotate: `${progress.value * 90}deg` }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const getUserInfo = async () => {
|
||||||
|
if (!token) {
|
||||||
|
setUserInfo(null);
|
||||||
|
setIsLoadingProfile(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoadingProfile(true);
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('token', token);
|
||||||
|
const response = await fetch(buildApiUrl(API_ENDPOINTS.USER_INFO), {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const data: UserInfoResponse = await response.json();
|
||||||
|
setUserInfo(data);
|
||||||
|
} catch {
|
||||||
|
Alert.alert('Erro', 'Nao foi possivel carregar os dados do perfil.');
|
||||||
|
} finally {
|
||||||
|
setIsLoadingProfile(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayName = useMemo(() => {
|
||||||
|
const fromApi = formatDisplayName(userInfo?.user?.nome, userInfo?.user?.apelido);
|
||||||
|
if (fromApi) return fromApi;
|
||||||
|
return formatDisplayName(authUser?.nome, authUser?.apelido);
|
||||||
|
}, [userInfo, authUser]);
|
||||||
|
|
||||||
|
const displayEmail = userInfo?.user?.email?.trim() || authUser?.email?.trim() || null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getUserInfo();
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const onRefresh = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await getUserInfo();
|
||||||
|
setRefreshing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (newPassword || confirmPassword) {
|
||||||
|
if (!newPassword || !confirmPassword) {
|
||||||
|
Alert.alert('Erro', 'Preenche os dois campos de palavra-passe.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
Alert.alert('Erro', 'As palavras-passe nao coincidem.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await updateProfile(
|
||||||
|
userInfo?.user?.nome,
|
||||||
|
userInfo?.user?.apelido,
|
||||||
|
undefined,
|
||||||
|
newPassword || undefined,
|
||||||
|
);
|
||||||
|
Alert.alert('Sucesso', 'Perfil atualizado com sucesso.');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
setIsEditingOpen(false);
|
||||||
|
await getUserInfo();
|
||||||
|
} catch (err) {
|
||||||
|
Alert.alert('Erro', err instanceof Error ? err.message : 'Nao foi possivel atualizar o perfil.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
setConfirmModal('logout');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAccount = () => {
|
||||||
|
setConfirmModal('delete');
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeleteAccount = () => {
|
||||||
|
Alert.alert(
|
||||||
|
'Confirmacao final',
|
||||||
|
'Todos os dados serao removidos permanentemente. Confirmar eliminacao?',
|
||||||
|
[
|
||||||
|
{ text: 'Cancelar', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Eliminar',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
if (!token) return;
|
||||||
|
setIsDeletingAccount(true);
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('token', token);
|
||||||
|
const response = await fetch(buildApiUrl(API_ENDPOINTS.DELETE_ACCOUNT), {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok && data?.status === 200) {
|
||||||
|
await logout();
|
||||||
|
router.replace('/login' as Href);
|
||||||
|
Alert.alert('Conta eliminada', 'A sua conta foi eliminada com sucesso.');
|
||||||
|
} else {
|
||||||
|
Alert.alert('Erro', data?.message || 'Nao foi possivel eliminar a conta.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Alert.alert('Erro', 'Falha ao contactar o servidor.');
|
||||||
|
} finally {
|
||||||
|
setIsDeletingAccount(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmModalAction = async () => {
|
||||||
|
if (confirmModal === 'logout') {
|
||||||
|
try {
|
||||||
|
await logout();
|
||||||
|
setConfirmModal(null);
|
||||||
|
router.replace('/login' as Href);
|
||||||
|
} catch {
|
||||||
|
Alert.alert('Erro', 'Nao foi possivel terminar sessao.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmModal === 'delete') {
|
||||||
|
setConfirmModal(null);
|
||||||
|
confirmDeleteAccount();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const keyboardVerticalOffset = Platform.OS === 'ios' ? insets.top + 56 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.container}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
keyboardVerticalOffset={keyboardVerticalOffset}>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.content}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
keyboardDismissMode="on-drag"
|
||||||
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
|
||||||
|
|
||||||
|
<Text style={styles.title}>O meu Perfil</Text>
|
||||||
|
|
||||||
|
<View style={styles.userCard}>
|
||||||
|
{isLoadingProfile && !displayName ? (
|
||||||
|
<LoadingSpinner size="small" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.userName}>{displayName || 'Utilizador'}</Text>
|
||||||
|
)}
|
||||||
|
<Text style={styles.userEmail}>{displayEmail || '—'}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.menuCard}>
|
||||||
|
<Pressable style={styles.menuRow} onPress={() => setIsEditingOpen((prev) => !prev)}>
|
||||||
|
<View style={styles.menuLeft}>
|
||||||
|
<UserIcon width={24} height={24} fill={colors.azul} />
|
||||||
|
<Text style={styles.menuText}>Editar Perfil</Text>
|
||||||
|
</View>
|
||||||
|
<Animated.View style={chevronAnimatedStyle}>
|
||||||
|
<ChevronRightIcon width={18} height={18} fill={colors.vermelho} />
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
style={[styles.editBodyWrap, editBodyAnimatedStyle]}
|
||||||
|
pointerEvents={isEditingOpen ? 'auto' : 'none'}>
|
||||||
|
<View
|
||||||
|
style={styles.editBodyMeasure}
|
||||||
|
onLayout={(e) => {
|
||||||
|
const h = Math.ceil(e.nativeEvent.layout.height);
|
||||||
|
if (h > 0) setMeasuredHeight(h);
|
||||||
|
}}>
|
||||||
|
<View style={styles.accordionContent}>
|
||||||
|
<View style={styles.rowInputs}>
|
||||||
|
<View style={styles.inputBlock}>
|
||||||
|
<Text style={styles.inputLabel}>Nome<Text style={styles.required}></Text></Text>
|
||||||
|
<TextInput
|
||||||
|
value={userInfo?.user?.nome || ''}
|
||||||
|
onChangeText={(text) =>
|
||||||
|
setUserInfo((prev) =>
|
||||||
|
prev?.user ? { ...prev, user: { ...prev.user, nome: text } } : prev,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Nome"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.inputBlock}>
|
||||||
|
<Text style={styles.inputLabel}>Apelido<Text style={styles.required}></Text></Text>
|
||||||
|
<TextInput
|
||||||
|
value={userInfo?.user?.apelido || ''}
|
||||||
|
onChangeText={(text) =>
|
||||||
|
setUserInfo((prev) =>
|
||||||
|
prev?.user ? { ...prev, user: { ...prev.user, apelido: text } } : prev,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Apelido"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.inputBlockFull}>
|
||||||
|
<Text style={styles.inputLabel}>Nova Palavra-passe<Text style={styles.required}></Text></Text>
|
||||||
|
<TextInput
|
||||||
|
value={newPassword}
|
||||||
|
onChangeText={setNewPassword}
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Palavra-passe"
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.inputBlockFull}>
|
||||||
|
<Text style={styles.inputLabel}>Confirmar Nova Palavra-passe<Text style={styles.required}></Text></Text>
|
||||||
|
<TextInput
|
||||||
|
value={confirmPassword}
|
||||||
|
onChangeText={setConfirmPassword}
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Palavra-passe"
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable style={styles.saveButton} onPress={handleSave} disabled={isSubmitting || isLoading}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<LoadingSpinner size="small" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={styles.saveButtonText}>Guardar Alteracoes</Text>
|
||||||
|
<Image source={require('@/assets/icons/seta-up.png')} style={styles.saveButtonIcon} />
|
||||||
|
</>
|
||||||
|
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<View style={styles.divider} />
|
||||||
|
|
||||||
|
<Pressable style={styles.menuRow} onPress={handleLogout}>
|
||||||
|
<View style={styles.menuLeft}>
|
||||||
|
<LogoutIcon width={24} height={24} fill={colors.azul} />
|
||||||
|
<Text style={styles.menuText}>Log out</Text>
|
||||||
|
</View>
|
||||||
|
<ChevronRightIcon width={18} height={18} fill={colors.vermelho} />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<View style={styles.divider} />
|
||||||
|
|
||||||
|
<Pressable style={styles.menuRow} onPress={handleDeleteAccount} disabled={isDeletingAccount}>
|
||||||
|
<View style={styles.menuLeft}>
|
||||||
|
<UserDeleteIcon width={24} height={24} fill={colors.azul} />
|
||||||
|
<Text style={styles.menuText}>{isDeletingAccount ? 'A eliminar...' : 'Eliminar Conta'}</Text>
|
||||||
|
</View>
|
||||||
|
<ChevronRightIcon width={18} height={18} fill={colors.vermelho} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{confirmModal && (
|
||||||
|
<Modal visible transparent animationType="fade" onRequestClose={() => setConfirmModal(null)}>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<View style={styles.modalCard}>
|
||||||
|
<View style={styles.modalIconWrap}>
|
||||||
|
{confirmModal === 'delete' ? (
|
||||||
|
<UserDeleteIcon width={24} height={24} fill={colors.branco} />
|
||||||
|
) : (
|
||||||
|
<LogoutIcon width={24} height={24} fill={colors.branco} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.modalTitle}>
|
||||||
|
{confirmModal === 'delete' ? 'Eliminar Conta' : 'Fazer Log out'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.modalSubtitle}>
|
||||||
|
{confirmModal === 'delete'
|
||||||
|
? 'Tens a certeza que queres eliminar a tua conta?'
|
||||||
|
: 'Tens a certeza que queres sair da tua conta?'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.modalButtons}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.modalButton, styles.modalPrimaryButton]}
|
||||||
|
onPress={confirmModalAction}
|
||||||
|
disabled={isDeletingAccount}>
|
||||||
|
<Text style={styles.modalPrimaryText}>
|
||||||
|
{confirmModal === 'delete' ? (isDeletingAccount ? 'A eliminar...' : 'Eliminar') : 'Sair'}
|
||||||
|
</Text>
|
||||||
|
<Image source={require('@/assets/icons/seta-up.png')} style={styles.saveButtonIcon} />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={[styles.modalButton, styles.modalSecondaryButton]}
|
||||||
|
onPress={() => setConfirmModal(null)}>
|
||||||
|
<Text style={styles.modalSecondaryText}>Cancelar</Text>
|
||||||
|
<Image source={require('@/assets/icons/seta-up-fill.png')} style={styles.arrowUpButtonIcon} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,24 +1,41 @@
|
|||||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
|
||||||
import { Stack } from 'expo-router';
|
|
||||||
import { StatusBar } from 'expo-status-bar';
|
|
||||||
import 'react-native-reanimated';
|
import 'react-native-reanimated';
|
||||||
|
import { EmergencyButton } from '@/assets/components/EmergencyButton';
|
||||||
|
import { AuthProvider } from '@/assets/contexts/useAuth';
|
||||||
|
import { useFonts } from 'expo-font';
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import * as SplashScreen from 'expo-splash-screen';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { View } from 'react-native';
|
||||||
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
|
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
export const unstable_settings = {
|
|
||||||
anchor: '(tabs)',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const colorScheme = useColorScheme();
|
const [fontsLoaded] = useFonts({
|
||||||
|
'SpaceGrotesk-Regular': require('@/assets/fonts/SpaceGrotesk-Regular.ttf'),
|
||||||
|
'SpaceGrotesk-Medium': require('@/assets/fonts/SpaceGrotesk-Medium.ttf'),
|
||||||
|
'SpaceGrotesk-Bold': require('@/assets/fonts/SpaceGrotesk-Bold.ttf'),
|
||||||
|
'SpaceGrotesk-SemiBold': require('@/assets/fonts/SpaceGrotesk-SemiBold.ttf'),
|
||||||
|
'SpaceGrotesk-Light': require('@/assets/fonts/SpaceGrotesk-Light.ttf'),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fontsLoaded) {
|
||||||
|
SplashScreen.hideAsync();
|
||||||
|
}
|
||||||
|
}, [fontsLoaded]);
|
||||||
|
|
||||||
|
if (!fontsLoaded) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<Stack>
|
<AuthProvider>
|
||||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
<View style={{ flex: 1 }}>
|
||||||
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
|
<Stack screenOptions={{ headerShown: false }} />
|
||||||
</Stack>
|
<EmergencyButton />
|
||||||
<StatusBar style="auto" />
|
</View>
|
||||||
</ThemeProvider>
|
</AuthProvider>
|
||||||
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
29
app/index.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { LoadingSpinner } from '@/assets/components/LoadingSpinner';
|
||||||
|
import { useAuth } from '@/assets/contexts/useAuth';
|
||||||
|
import { type Href, router } from 'expo-router';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { View } from 'react-native';
|
||||||
|
import styles from '@/styles/screens/root/index.styles';
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading) {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
// Se estiver autenticado, redirecionar para home
|
||||||
|
router.replace('/home' as Href);
|
||||||
|
} else {
|
||||||
|
// Se não estiver autenticado, redirecionar para login
|
||||||
|
router.replace('/login' as Href);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, isLoading]);
|
||||||
|
|
||||||
|
// Mostrar loading enquanto verifica autenticação
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<LoadingSpinner size="large" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { Link } from 'expo-router';
|
|
||||||
import { StyleSheet } from 'react-native';
|
|
||||||
|
|
||||||
import { ThemedText } from '@/components/themed-text';
|
|
||||||
import { ThemedView } from '@/components/themed-view';
|
|
||||||
|
|
||||||
export default function ModalScreen() {
|
|
||||||
return (
|
|
||||||
<ThemedView style={styles.container}>
|
|
||||||
<ThemedText type="title">This is a modal</ThemedText>
|
|
||||||
<Link href="/" dismissTo style={styles.link}>
|
|
||||||
<ThemedText type="link">Go to home screen</ThemedText>
|
|
||||||
</Link>
|
|
||||||
</ThemedView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: 20,
|
|
||||||
},
|
|
||||||
link: {
|
|
||||||
marginTop: 15,
|
|
||||||
paddingVertical: 15,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
401
app/reserva/[referencia].tsx
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
import { LoadingSpinner } from '@/assets/components/LoadingSpinner';
|
||||||
|
import { DashedDivider } from '@/assets/components/reserva/DashedDivider';
|
||||||
|
import { DocumentoRow } from '@/assets/components/reserva/DocumentoRow';
|
||||||
|
import {
|
||||||
|
formatCurrency,
|
||||||
|
formatDateLong,
|
||||||
|
formatDateShort,
|
||||||
|
getImageUrl,
|
||||||
|
} from '@/assets/components/reserva/formatters';
|
||||||
|
import { HotelCard } from '@/assets/components/reserva/HotelCard';
|
||||||
|
import { PassageiroCard } from '@/assets/components/reserva/PassageiroCard';
|
||||||
|
import { Section } from '@/assets/components/reserva/Section';
|
||||||
|
import { StatusBadge } from '@/assets/components/reserva/StatusBadge';
|
||||||
|
import { ValorReservaCard } from '@/assets/components/reserva/ValorReservaCard';
|
||||||
|
import { VooCard } from '@/assets/components/reserva/VooCard';
|
||||||
|
import { API_ENDPOINTS, buildApiUrl } from '@/assets/config/api';
|
||||||
|
import { useAuth } from '@/assets/contexts/useAuth';
|
||||||
|
import { cacheReservaFull, getCachedReservaFull } from '@/assets/services/offlineStorage';
|
||||||
|
import {
|
||||||
|
Documento,
|
||||||
|
Escala,
|
||||||
|
JsonData,
|
||||||
|
Pagamento,
|
||||||
|
ReservaData,
|
||||||
|
ReservaResponse,
|
||||||
|
VooDirection,
|
||||||
|
VooSegment,
|
||||||
|
} from '@/assets/types';
|
||||||
|
import styles from '@/styles/screens/reserva/detail.styles';
|
||||||
|
import { FontAwesome } from '@expo/vector-icons';
|
||||||
|
import { Stack, useLocalSearchParams } from 'expo-router';
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Image,
|
||||||
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
export default function ReservaDetalheScreen() {
|
||||||
|
const { referencia } = useLocalSearchParams<{ referencia: string }>();
|
||||||
|
const { token } = useAuth();
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [reservaData, setReservaData] = useState<ReservaData | null>(null);
|
||||||
|
const [pagamentos, setPagamentos] = useState<Pagamento[]>([]);
|
||||||
|
const [documentos, setDocumentos] = useState<Documento[]>([]);
|
||||||
|
const [expandedPassageiros, setExpandedPassageiros] = useState<Set<number>>(new Set());
|
||||||
|
const [cachedAt, setCachedAt] = useState<string | null>(null);
|
||||||
|
const [isFromCache, setIsFromCache] = useState(false);
|
||||||
|
|
||||||
|
const togglePassageiro = (idx: number) => {
|
||||||
|
setExpandedPassageiros((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(idx)) {
|
||||||
|
next.delete(idx);
|
||||||
|
} else {
|
||||||
|
next.add(idx);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchReserva = async () => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
if (!token || !referencia) {
|
||||||
|
throw new Error('Dados de autenticacao em falta');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('token', token);
|
||||||
|
formData.append('referenciaViagem', referencia);
|
||||||
|
|
||||||
|
const response = await fetch(buildApiUrl(API_ENDPOINTS.GET_RESERVA), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: ReservaResponse = await response.json();
|
||||||
|
if (!response.ok || data.status !== 200 || !data.reserva) {
|
||||||
|
throw new Error(data.message || 'Nao foi possivel carregar a reserva');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pagamentosData = data.pagamentos || [];
|
||||||
|
const documentosData = data.documentos || [];
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
setReservaData(data.reserva);
|
||||||
|
setPagamentos(pagamentosData);
|
||||||
|
setDocumentos(documentosData);
|
||||||
|
setCachedAt(now);
|
||||||
|
setIsFromCache(false);
|
||||||
|
|
||||||
|
await cacheReservaFull(referencia, {
|
||||||
|
reservaData: data.reserva,
|
||||||
|
pagamentos: pagamentosData,
|
||||||
|
documentos: documentosData,
|
||||||
|
cachedAt: now,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
const cached = await getCachedReservaFull(referencia);
|
||||||
|
if (cached) {
|
||||||
|
setReservaData(cached.reservaData);
|
||||||
|
setPagamentos(cached.pagamentos);
|
||||||
|
setDocumentos(cached.documentos);
|
||||||
|
setCachedAt(cached.cachedAt);
|
||||||
|
setIsFromCache(true);
|
||||||
|
setError(null);
|
||||||
|
} else {
|
||||||
|
setError('Sem ligação à internet e sem dados guardados para esta reserva.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchReserva();
|
||||||
|
}, [referencia, token]);
|
||||||
|
|
||||||
|
const onRefresh = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await fetchReserva();
|
||||||
|
setRefreshing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsedJsonData = useMemo((): JsonData | null => {
|
||||||
|
if (!reservaData?.jsonData) return null;
|
||||||
|
try {
|
||||||
|
return typeof reservaData.jsonData === 'string'
|
||||||
|
? (JSON.parse(reservaData.jsonData) as JsonData)
|
||||||
|
: reservaData.jsonData;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [reservaData?.jsonData]);
|
||||||
|
|
||||||
|
const hotelInfo = parsedJsonData?.hotel || [];
|
||||||
|
const extrasData = parsedJsonData?.extras ?? parsedJsonData?.extra ?? [];
|
||||||
|
const voosData = parsedJsonData?.voo;
|
||||||
|
|
||||||
|
const getVoosFromLeg = (leg?: VooDirection): (Escala | VooSegment)[] => {
|
||||||
|
if (!leg) return [];
|
||||||
|
if (Array.isArray(leg)) return leg;
|
||||||
|
return leg.infoEscalas ?? [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const voosIda = getVoosFromLeg(voosData?.departure);
|
||||||
|
const voosVolta = getVoosFromLeg(voosData?.arrival);
|
||||||
|
|
||||||
|
const buildQuartosPassageiros = (data: ReservaData) => {
|
||||||
|
const partes: string[] = [];
|
||||||
|
if (data.quartos) partes.push(`${data.quartos} Quartos`);
|
||||||
|
if (data.adultos) partes.push(`${data.adultos} Adultos`);
|
||||||
|
if (data.criancas && data.criancas !== '0') partes.push(`${data.criancas} Crianças`);
|
||||||
|
return partes.length ? partes.join(' · ') : `${data.pessoas} Passageiros`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusCode = String(reservaData?.status ?? '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
headerTitleAlign: 'center',
|
||||||
|
headerBackButtonDisplayMode: 'minimal',
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerStyle: { backgroundColor: '#F4F7FE' },
|
||||||
|
headerTitle: () => (
|
||||||
|
<Image
|
||||||
|
source={require('@/assets/icons/logotipo-azul.png')}
|
||||||
|
style={styles.logo}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<View style={styles.centered}>
|
||||||
|
<LoadingSpinner size="large" />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.content}
|
||||||
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
|
||||||
|
{!!error && <Text style={styles.errorText}>{error}</Text>}
|
||||||
|
|
||||||
|
{reservaData && (
|
||||||
|
<>
|
||||||
|
{isFromCache && (
|
||||||
|
<View style={styles.offlineBanner}>
|
||||||
|
<FontAwesome name="wifi" size={13} color="#fff" />
|
||||||
|
<Text style={styles.offlineBannerText}>Sem ligação — a mostrar dados guardados</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={styles.destination}>{reservaData.destino}</Text>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
{!!reservaData.imagemCidade && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: getImageUrl(reservaData.imagemCidade) }}
|
||||||
|
style={styles.summaryImage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<View style={styles.rowBetween}>
|
||||||
|
<View style={styles.idBlock}>
|
||||||
|
<Text style={styles.idTop}>ID #{reservaData.referenciaViagem}</Text>
|
||||||
|
<Text style={styles.fieldLabel}>Ref. da Reserva</Text>
|
||||||
|
</View>
|
||||||
|
<StatusBadge statusCode={statusCode} />
|
||||||
|
</View>
|
||||||
|
<DashedDivider width={0.5} borderStyle="solid" />
|
||||||
|
<View>
|
||||||
|
<Text style={styles.idTop}>{reservaData.localizador || '---'}</Text>
|
||||||
|
<Text style={styles.fieldLabel}>Ref. do Operador</Text>
|
||||||
|
</View>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Resumo da Reserva">
|
||||||
|
<DashedDivider width={1} borderStyle="dashed" />
|
||||||
|
<View style={styles.rowLineNoBorder}>
|
||||||
|
<Text style={styles.rowLabel}>Destino</Text>
|
||||||
|
<Text style={styles.rowValue}>{reservaData.destino}</Text>
|
||||||
|
</View>
|
||||||
|
<DashedDivider width={1} borderStyle="dashed" />
|
||||||
|
<View style={styles.rowLineNoBorder}>
|
||||||
|
<Text style={styles.rowLabel}>Data Viagem</Text>
|
||||||
|
<Text style={styles.rowValue}>
|
||||||
|
{formatDateLong(reservaData.startDate)} -{' '}
|
||||||
|
{formatDateLong(reservaData.endDate)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<DashedDivider width={1} borderStyle="dashed" />
|
||||||
|
<View style={styles.rowLineNoBorder}>
|
||||||
|
<Text style={styles.rowLabel}>Nr. de Noites</Text>
|
||||||
|
<Text style={styles.rowValue}>{reservaData.noites} noites</Text>
|
||||||
|
</View>
|
||||||
|
<DashedDivider width={1} borderStyle="dashed" />
|
||||||
|
<View style={styles.rowLineNoBorder}>
|
||||||
|
<Text style={styles.rowLabel}>Quartos e Passageiros</Text>
|
||||||
|
<Text style={styles.rowValue}>{buildQuartosPassageiros(reservaData)}</Text>
|
||||||
|
</View>
|
||||||
|
<DashedDivider width={1} borderStyle="dashed" />
|
||||||
|
<View style={styles.rowLineNoBorder}>
|
||||||
|
<Text style={styles.rowLabel}>Operador</Text>
|
||||||
|
<Text style={styles.rowValue}>{reservaData.operador || '---'}</Text>
|
||||||
|
</View>
|
||||||
|
<DashedDivider width={1} borderStyle="dashed" />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Alojamento">
|
||||||
|
{hotelInfo.length > 0 ? (
|
||||||
|
hotelInfo.map((hotel, idx) => (
|
||||||
|
<HotelCard
|
||||||
|
key={`hotel-${hotel.id_hotel}-${idx}`}
|
||||||
|
hotel={hotel}
|
||||||
|
isFirst={idx === 0}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text style={styles.empty}>Sem dados de alojamento.</Text>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Voos">
|
||||||
|
{voosIda.length > 0 || voosVolta.length > 0 ? (
|
||||||
|
<View style={styles.vooList}>
|
||||||
|
{voosIda.length > 0 && (
|
||||||
|
<VooCard segmentos={voosIda} tipo="ida" />
|
||||||
|
)}
|
||||||
|
{voosVolta.length > 0 && (
|
||||||
|
<VooCard segmentos={voosVolta} tipo="volta" />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.empty}>Sem dados de voos.</Text>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Passageiros">
|
||||||
|
<DashedDivider width={1} borderStyle="dashed" />
|
||||||
|
{reservaData.passageiros && reservaData.passageiros.length > 0 ? (
|
||||||
|
reservaData.passageiros.map((passageiro, idx) => (
|
||||||
|
<PassageiroCard
|
||||||
|
key={`passageiro-${idx}`}
|
||||||
|
passageiro={passageiro}
|
||||||
|
index={idx}
|
||||||
|
isFirst={idx === 0}
|
||||||
|
isOpen={expandedPassageiros.has(idx)}
|
||||||
|
onToggle={() => togglePassageiro(idx)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text style={styles.empty}>Sem passageiros associados.</Text>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Extras">
|
||||||
|
{extrasData.length > 0 ? (
|
||||||
|
<View style={styles.extrasList}>
|
||||||
|
{extrasData.map((extra) => (
|
||||||
|
<View style={styles.extrasItem} key={extra.name}>
|
||||||
|
<Text style={styles.extrasItemText}>{extra.name}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.empty}>Sem extras associados.</Text>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Histórico de Pagamentos">
|
||||||
|
{pagamentos.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<View style={styles.tableHeader}>
|
||||||
|
<View style={styles.tableCellLeft}>
|
||||||
|
<Text style={styles.tableHeaderText}>Data</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.tableCellMid}>
|
||||||
|
<Text style={styles.tableHeaderText}>Valor</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.tableCellRight}>
|
||||||
|
<Text style={styles.tableHeaderText}>Meio Pag.</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<DashedDivider width={1} borderStyle="dashed" />
|
||||||
|
{pagamentos.map((pagamento, idx) => (
|
||||||
|
<React.Fragment key={`pagamento-${idx}`}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.tableRow,
|
||||||
|
idx === pagamentos.length - 1 && styles.tableRowLast,
|
||||||
|
]}>
|
||||||
|
<View style={styles.tableCellLeft}>
|
||||||
|
<Text style={styles.tableValueText}>
|
||||||
|
{formatDateShort(pagamento.data_pagamento || pagamento.data_hora)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.tableCellMid}>
|
||||||
|
<Text style={styles.tableValueText}>
|
||||||
|
{formatCurrency(pagamento.valor)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.tableCellRight}>
|
||||||
|
<Text style={styles.tableValueText}>{pagamento.metodoPagamento}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<DashedDivider width={1} borderStyle="dashed" />
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.empty}>Sem pagamentos registados.</Text>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<ValorReservaCard reserva={reservaData} />
|
||||||
|
|
||||||
|
<Section title="Documentos">
|
||||||
|
<DashedDivider width={1} borderStyle="dashed" />
|
||||||
|
{documentos.length > 0 ? (
|
||||||
|
documentos.map((documento, idx) => (
|
||||||
|
<React.Fragment key={`documento-${idx}`}>
|
||||||
|
<DocumentoRow
|
||||||
|
documento={documento}
|
||||||
|
isLast={idx === documentos.length - 1}
|
||||||
|
/>
|
||||||
|
<DashedDivider width={1} borderStyle="dashed" />
|
||||||
|
</React.Fragment>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text style={styles.empty}>Sem documentos associados.</Text>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{!!cachedAt && (
|
||||||
|
<Text style={styles.syncLabel}>
|
||||||
|
Atualizado em: {new Date(cachedAt).toLocaleDateString('pt-PT', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
assets/components/EmergencyButton.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { useAuth } from '@/assets/contexts/useAuth';
|
||||||
|
import { getCachedContacts } from '@/assets/services/offlineStorage';
|
||||||
|
import { colors } from '@/assets/styles/colors';
|
||||||
|
import { fonts } from '@/assets/styles/fonts';
|
||||||
|
import { FontAwesome } from '@expo/vector-icons';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Alert, Linking, Platform, StyleSheet, Text, useWindowDimensions } from 'react-native';
|
||||||
|
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||||
|
import Animated, {
|
||||||
|
runOnJS,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withSpring,
|
||||||
|
withTiming,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
const BTN_SIZE = 64;
|
||||||
|
const MARGIN = 16;
|
||||||
|
|
||||||
|
export function EmergencyButton() {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
|
||||||
|
const [emergencyPhone, setEmergencyPhone] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const tabBarHeight = (Platform.OS === 'ios' ? 54 : 64) + insets.bottom;
|
||||||
|
const maxX = screenWidth - BTN_SIZE - MARGIN;
|
||||||
|
const minY = insets.top + MARGIN;
|
||||||
|
const maxY = screenHeight - tabBarHeight - BTN_SIZE - MARGIN;
|
||||||
|
|
||||||
|
const x = useSharedValue(screenWidth - BTN_SIZE - MARGIN);
|
||||||
|
const y = useSharedValue(screenHeight - tabBarHeight - BTN_SIZE - MARGIN);
|
||||||
|
const startX = useSharedValue(0);
|
||||||
|
const startY = useSharedValue(0);
|
||||||
|
const scale = useSharedValue(1);
|
||||||
|
const isDragActive = useSharedValue(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) return;
|
||||||
|
getCachedContacts().then(({ contactData }) => {
|
||||||
|
if (contactData?.emergencyPhone) {
|
||||||
|
setEmergencyPhone(contactData.emergencyPhone);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
if (!emergencyPhone) return;
|
||||||
|
const url = `tel:${emergencyPhone.replace(/\s/g, '')}`;
|
||||||
|
Alert.alert(
|
||||||
|
'Linha de Emergência 24h',
|
||||||
|
`Ligar para ${emergencyPhone}?`,
|
||||||
|
[
|
||||||
|
{ text: 'Cancelar', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Ligar',
|
||||||
|
style: 'default',
|
||||||
|
onPress: () =>
|
||||||
|
Linking.openURL(url).catch(() =>
|
||||||
|
Alert.alert('Erro', 'Não foi possível abrir a aplicação de chamadas.'),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toque simples → abre o Alert
|
||||||
|
const tap = Gesture.Tap()
|
||||||
|
.maxDuration(500)
|
||||||
|
.onEnd(() => {
|
||||||
|
runOnJS(handlePress)();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Segurar 800ms → ativa modo arrasto com feedback de escala
|
||||||
|
const longPress = Gesture.LongPress()
|
||||||
|
.minDuration(800)
|
||||||
|
.onStart(() => {
|
||||||
|
isDragActive.value = true;
|
||||||
|
scale.value = withSpring(1.2, { damping: 12, stiffness: 200 });
|
||||||
|
startX.value = x.value;
|
||||||
|
startY.value = y.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pan → só move quando o modo arrasto está ativo
|
||||||
|
const pan = Gesture.Pan()
|
||||||
|
.onUpdate((e) => {
|
||||||
|
if (!isDragActive.value) return;
|
||||||
|
x.value = Math.max(MARGIN, Math.min(maxX, startX.value + e.translationX));
|
||||||
|
y.value = Math.max(minY, Math.min(maxY, startY.value + e.translationY));
|
||||||
|
})
|
||||||
|
.onEnd(() => {
|
||||||
|
if (!isDragActive.value) return;
|
||||||
|
isDragActive.value = false;
|
||||||
|
scale.value = withSpring(1, { damping: 15, stiffness: 250 });
|
||||||
|
// snap para a borda mais próxima sem bounce
|
||||||
|
const snapRight = x.value + BTN_SIZE / 2 > screenWidth / 2;
|
||||||
|
x.value = withTiming(snapRight ? maxX : MARGIN, { duration: 250 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const composed = Gesture.Race(
|
||||||
|
tap,
|
||||||
|
Gesture.Simultaneous(longPress, pan),
|
||||||
|
);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [
|
||||||
|
{ translateX: x.value },
|
||||||
|
{ translateY: y.value },
|
||||||
|
{ scale: scale.value },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!isAuthenticated || !emergencyPhone) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GestureDetector gesture={composed}>
|
||||||
|
<Animated.View style={[styles.btn, animatedStyle]}>
|
||||||
|
<FontAwesome name="phone" size={18} color={colors.branco} />
|
||||||
|
<Text style={styles.label}>SOS</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</GestureDetector>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
btn: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: BTN_SIZE,
|
||||||
|
height: BTN_SIZE,
|
||||||
|
borderRadius: BTN_SIZE / 2,
|
||||||
|
backgroundColor: colors.vermelho,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 4,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 8,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
elevation: 10,
|
||||||
|
zIndex: 999,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: colors.branco,
|
||||||
|
fontFamily: fonts.bold,
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
});
|
||||||
46
assets/components/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Image, ImageStyle, StyleProp } from 'react-native';
|
||||||
|
import Animated, {
|
||||||
|
Easing,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withRepeat,
|
||||||
|
withTiming,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
|
||||||
|
const SIZES = {
|
||||||
|
small: 24,
|
||||||
|
large: 48,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
size?: keyof typeof SIZES | number;
|
||||||
|
style?: StyleProp<ImageStyle>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LoadingSpinner({ size = 'large', style }: Props) {
|
||||||
|
const dimension = typeof size === 'number' ? size : SIZES[size];
|
||||||
|
const rotation = useSharedValue(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
rotation.value = withRepeat(
|
||||||
|
withTiming(360, { duration: 1000, easing: Easing.linear }),
|
||||||
|
-1,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}, [rotation]);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ rotate: `${rotation.value}deg` }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={animatedStyle}>
|
||||||
|
<Image
|
||||||
|
source={require('@/assets/icons/logo.png')}
|
||||||
|
style={[{ width: dimension, height: dimension }, style]}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
assets/components/auth/RecoverScreenLayout.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import styles from '@/styles/screens/auth/recover.styles';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
Image,
|
||||||
|
ImageBackground,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
StatusBar,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RecoverScreenLayout({ title, subtitle, children }: Props) {
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.container}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 20}>
|
||||||
|
<StatusBar barStyle="light-content" />
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
bounces={false}>
|
||||||
|
<ImageBackground
|
||||||
|
source={require('@/assets/images/banner-login.png')}
|
||||||
|
style={styles.hero}
|
||||||
|
imageStyle={styles.heroImage}>
|
||||||
|
<View style={styles.overlay} />
|
||||||
|
<View style={styles.heroContent}>
|
||||||
|
<Image
|
||||||
|
source={require('@/assets/icons/logotipo-branco.png')}
|
||||||
|
style={styles.logo}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
<Text style={styles.title}>{title}</Text>
|
||||||
|
<Text style={styles.subtitle}>{subtitle}</Text>
|
||||||
|
</View>
|
||||||
|
</ImageBackground>
|
||||||
|
|
||||||
|
<View style={styles.formCard}>{children}</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
assets/components/reserva/DashedDivider.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { View } from 'react-native';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
color?: string;
|
||||||
|
width?: number;
|
||||||
|
borderStyle?: 'dashed' | 'dotted' | 'solid';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DashedDivider({ color = '#a9bcd9', width = 1, borderStyle = 'dashed' }: Props) {
|
||||||
|
return (
|
||||||
|
<View style={{ height: 1, overflow: 'hidden' }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
borderWidth: width,
|
||||||
|
borderColor: color,
|
||||||
|
borderStyle: borderStyle,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
assets/components/reserva/DocumentoRow.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { LoadingSpinner } from '@/assets/components/LoadingSpinner';
|
||||||
|
import { colors } from '@/assets/styles/colors';
|
||||||
|
import { downloadDocumento, getLocalDocumentUri } from '@/assets/services/documentSync';
|
||||||
|
import { Documento } from '@/assets/types';
|
||||||
|
import styles from '@/styles/screens/reserva/detail.styles';
|
||||||
|
import { FontAwesome } from '@expo/vector-icons';
|
||||||
|
import CircleCheckIcon from '@/assets/icons/circle-check-solid.svg';
|
||||||
|
import * as Sharing from 'expo-sharing';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Alert, Pressable, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
documento: Documento;
|
||||||
|
isLast: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDocumentExt = (path: string) => {
|
||||||
|
const ext = path.split('.').pop()?.toUpperCase() ?? 'DOC';
|
||||||
|
return ext.length > 4 ? 'DOC' : ext;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DocumentoRow({ documento, isLast }: Props) {
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
const [isDownloaded, setIsDownloaded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!documento.idDocumento || !documento.caminhoFicheiro) return;
|
||||||
|
getLocalDocumentUri(documento.idDocumento, documento.caminhoFicheiro).then((uri) => {
|
||||||
|
setIsDownloaded(!!uri);
|
||||||
|
});
|
||||||
|
}, [documento.idDocumento, documento.caminhoFicheiro]);
|
||||||
|
|
||||||
|
const fileName =
|
||||||
|
documento.nome || documento.caminhoFicheiro.split('/').pop() || '---';
|
||||||
|
|
||||||
|
const openLocalFile = async (uri: string) => {
|
||||||
|
const canShare = await Sharing.isAvailableAsync();
|
||||||
|
if (canShare) {
|
||||||
|
await Sharing.shareAsync(uri, { UTI: '.pdf', mimeType: 'application/pdf' });
|
||||||
|
} else {
|
||||||
|
Alert.alert('Erro', 'Não é possível abrir este ficheiro neste dispositivo.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePress = async () => {
|
||||||
|
if (!documento.idDocumento || !documento.caminhoFicheiro) return;
|
||||||
|
|
||||||
|
setIsDownloading(true);
|
||||||
|
try {
|
||||||
|
const localUri = await getLocalDocumentUri(documento.idDocumento, documento.caminhoFicheiro);
|
||||||
|
|
||||||
|
if (localUri) {
|
||||||
|
await openLocalFile(localUri);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ficheiro não está local — fazer download agora
|
||||||
|
const uri = await downloadDocumento({
|
||||||
|
idDocumento: documento.idDocumento,
|
||||||
|
caminhoFicheiro: documento.caminhoFicheiro,
|
||||||
|
checksum: documento.checksum ?? '',
|
||||||
|
nome: documento.nome,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (uri) {
|
||||||
|
setIsDownloaded(true);
|
||||||
|
await openLocalFile(uri);
|
||||||
|
} else {
|
||||||
|
Alert.alert('Erro', 'Não foi possível descarregar o documento. Verifica a tua ligação à internet.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={handlePress}
|
||||||
|
disabled={isDownloading}
|
||||||
|
style={[styles.documentoRow, isLast && styles.documentoRowLast]}>
|
||||||
|
<View style={styles.documentoBadge}>
|
||||||
|
<Text style={styles.documentoBadgeText}>
|
||||||
|
{getDocumentExt(documento.caminhoFicheiro)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.documentoName} numberOfLines={1}>
|
||||||
|
{fileName}
|
||||||
|
</Text>
|
||||||
|
{isDownloading ? (
|
||||||
|
<LoadingSpinner size="small" />
|
||||||
|
) : isDownloaded ? (
|
||||||
|
<CircleCheckIcon width={16} height={16} fill="#13AE45" />
|
||||||
|
) : (
|
||||||
|
<FontAwesome name="download" size={16} color={colors.vermelho} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
assets/components/reserva/FieldBox.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import styles from '@/styles/screens/reserva/detail.styles';
|
||||||
|
import { Text, View } from 'react-native';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
label: string;
|
||||||
|
value?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FieldBox({ label, value }: Props) {
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text style={styles.fieldLabel}>{label}</Text>
|
||||||
|
<View style={styles.fieldBox}>
|
||||||
|
<Text style={styles.fieldBoxText}>{value || '---'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
assets/components/reserva/HotelCard.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { HotelInfo } from '@/assets/types';
|
||||||
|
import styles from '@/styles/screens/reserva/detail.styles';
|
||||||
|
import { Image, Text, View } from 'react-native';
|
||||||
|
import { formatDateLong } from './formatters';
|
||||||
|
import { Stars } from './Stars';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
hotel: HotelInfo;
|
||||||
|
isFirst: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HotelCard({ hotel, isFirst }: Props) {
|
||||||
|
const img = hotel.imagemexterna || hotel.imageminterna;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.hotelRow, !isFirst && styles.blockBorderTop]}>
|
||||||
|
<Image
|
||||||
|
source={img ? { uri: img } : require('@/assets/icons/logo.png')}
|
||||||
|
style={styles.hotelImage}
|
||||||
|
/>
|
||||||
|
<View style={styles.hotelInfo}>
|
||||||
|
<Stars stars={hotel.stars} />
|
||||||
|
<Text style={styles.hotelName} numberOfLines={2}>
|
||||||
|
{hotel.name}
|
||||||
|
</Text>
|
||||||
|
{!!hotel.regime && (
|
||||||
|
<Text style={styles.hotelMetaLine}>
|
||||||
|
<Text style={styles.hotelMetaLabel}>Regime: </Text>
|
||||||
|
<Text style={styles.hotelMetaValue}>{hotel.regime}</Text>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{!!hotel.data && (
|
||||||
|
<Text style={styles.hotelMetaLine}>
|
||||||
|
<Text style={styles.hotelMetaLabel}>Check-In: </Text>
|
||||||
|
<Text style={styles.hotelMetaValue}>{formatDateLong(hotel.data)}</Text>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text style={styles.hotelMetaLine}>
|
||||||
|
<Text style={styles.hotelMetaLabel}>Nr. de Noites: </Text>
|
||||||
|
<Text style={styles.hotelMetaValue}>
|
||||||
|
{hotel.noites} {hotel.noites === 1 ? 'noite' : 'noites'}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
assets/components/reserva/PassageiroCard.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { colors } from '@/assets/styles/colors';
|
||||||
|
import { Passageiro } from '@/assets/types';
|
||||||
|
import styles from '@/styles/screens/reserva/detail.styles';
|
||||||
|
import { FontAwesome } from '@expo/vector-icons';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Pressable, Text, View } from 'react-native';
|
||||||
|
import Animated, {
|
||||||
|
Easing,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
import { DashedDivider } from './DashedDivider';
|
||||||
|
import { FieldBox } from './FieldBox';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
passageiro: Passageiro;
|
||||||
|
index: number;
|
||||||
|
isOpen: boolean;
|
||||||
|
isFirst: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ANIM_DURATION = 280;
|
||||||
|
|
||||||
|
export function PassageiroCard({ passageiro, index, isOpen, onToggle }: Props) {
|
||||||
|
const progress = useSharedValue(isOpen ? 1 : 0);
|
||||||
|
const contentHeight = useSharedValue(0);
|
||||||
|
const [measuredHeight, setMeasuredHeight] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (measuredHeight > 0) {
|
||||||
|
contentHeight.value = measuredHeight;
|
||||||
|
}
|
||||||
|
}, [measuredHeight, contentHeight]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
progress.value = withTiming(isOpen ? 1 : 0, {
|
||||||
|
duration: ANIM_DURATION,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
});
|
||||||
|
}, [isOpen, progress]);
|
||||||
|
|
||||||
|
const bodyAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
height: contentHeight.value * progress.value,
|
||||||
|
opacity: progress.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const chevronAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ rotate: `${progress.value * 180}deg` }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<View style={styles.passageiroContainer}>
|
||||||
|
<Pressable onPress={onToggle} style={styles.passageiroHeaderRow}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.passageiroSubtitle}>Passageiro {index + 1}</Text>
|
||||||
|
<Text style={styles.passageiroName}>
|
||||||
|
{passageiro.nome} {passageiro.sobrenome}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Animated.View style={chevronAnimatedStyle}>
|
||||||
|
<FontAwesome name="chevron-down" size={14} color={colors.vermelho} />
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
style={[styles.passageiroBodyWrap, bodyAnimatedStyle]}
|
||||||
|
pointerEvents={isOpen ? 'auto' : 'none'}>
|
||||||
|
<View
|
||||||
|
style={styles.passageiroBodyMeasure}
|
||||||
|
onLayout={(e) => {
|
||||||
|
const h = Math.ceil(e.nativeEvent.layout.height);
|
||||||
|
if (h > 0) setMeasuredHeight(h);
|
||||||
|
}}>
|
||||||
|
<View style={styles.passageiroBody}>
|
||||||
|
<View style={styles.twoCol}>
|
||||||
|
<View style={styles.twoColItem}>
|
||||||
|
<FieldBox label="Nome" value={passageiro.nome} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.twoColItem}>
|
||||||
|
<FieldBox label="Apelido" value={passageiro.sobrenome} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.twoCol}>
|
||||||
|
<View style={styles.twoColItem}>
|
||||||
|
<FieldBox label="Data de Nasc." value={passageiro.dataNascimento} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.twoColItem}>
|
||||||
|
<FieldBox label="Género" value={passageiro.genero} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.twoCol}>
|
||||||
|
<View style={styles.twoColItem}>
|
||||||
|
<FieldBox label="Nacionalidade" value={passageiro.nacionalidade} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.twoColItem}>
|
||||||
|
<FieldBox label="Telemóvel" value={passageiro.telemovel} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FieldBox label="Morada" value={passageiro.morada} />
|
||||||
|
<FieldBox label="Nacionalidade Doc." value={passageiro.paisEmissao} />
|
||||||
|
<FieldBox label="Nr Doc Identificação" value={passageiro.numeroDocumento} />
|
||||||
|
|
||||||
|
<View style={styles.twoCol}>
|
||||||
|
<View style={styles.twoColItem}>
|
||||||
|
<FieldBox label="Data Emissão" value={passageiro.dataDeEmissao} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.twoColItem}>
|
||||||
|
<FieldBox label="Data Val./Exp." value={passageiro.dataDeValidade} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FieldBox label="Email" value={passageiro.email} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
<DashedDivider width={1} borderStyle="dashed" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
assets/components/reserva/Section.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import styles from '@/styles/screens/reserva/detail.styles';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { Text, View } from 'react-native';
|
||||||
|
import { DashedDivider } from './DashedDivider';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Section({ title, children }: Props) {
|
||||||
|
return (
|
||||||
|
<View style={styles.summaryCard}>
|
||||||
|
{!!title && (
|
||||||
|
<>
|
||||||
|
<Text style={styles.sectionTitle}>{title}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
assets/components/reserva/Stars.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { colors } from '@/assets/styles/colors';
|
||||||
|
import styles from '@/styles/screens/reserva/detail.styles';
|
||||||
|
import { FontAwesome } from '@expo/vector-icons';
|
||||||
|
import { View } from 'react-native';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
stars: string | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Stars({ stars }: Props) {
|
||||||
|
const count = Math.max(0, Math.min(5, parseInt(String(stars || '0'), 10)));
|
||||||
|
if (!count) return null;
|
||||||
|
return (
|
||||||
|
<View style={styles.hotelStarsRow}>
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<FontAwesome key={`star-${i}`} name="star" size={16} color={colors.stars} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
assets/components/reserva/StatusBadge.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import styles from '@/styles/screens/reserva/detail.styles';
|
||||||
|
import { FontAwesome } from '@expo/vector-icons';
|
||||||
|
import { ComponentProps } from 'react';
|
||||||
|
import { Text, View } from 'react-native';
|
||||||
|
|
||||||
|
type StatusType = 'verde' | 'vermelho' | 'roxo' | 'amarelo' | 'neutral';
|
||||||
|
type IconName = ComponentProps<typeof FontAwesome>['name'];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
statusCode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusType = (statusCode: string): StatusType => {
|
||||||
|
if (statusCode === '1' || statusCode === '5') return 'verde';
|
||||||
|
if (statusCode === '-5') return 'vermelho';
|
||||||
|
if (statusCode === '10' || statusCode === '99') return 'roxo';
|
||||||
|
if (statusCode === '-2' || statusCode === '-1' || statusCode === '-3') return 'amarelo';
|
||||||
|
return 'neutral';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (type: StatusType) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'verde':
|
||||||
|
return '#13AE45';
|
||||||
|
case 'vermelho':
|
||||||
|
return '#E6463B';
|
||||||
|
case 'roxo':
|
||||||
|
return '#B138E6';
|
||||||
|
case 'amarelo':
|
||||||
|
return '#C99700';
|
||||||
|
default:
|
||||||
|
return '#4A6592';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusConfig = (statusCode: string): { label: string; icon: IconName } => {
|
||||||
|
switch (statusCode) {
|
||||||
|
case '1':
|
||||||
|
return { label: 'Confirmada', icon: 'check-circle' };
|
||||||
|
case '5':
|
||||||
|
return { label: 'Confirmada - Aguarda pagamento total', icon: 'check-circle' };
|
||||||
|
case '-5':
|
||||||
|
return { label: 'Cancelada', icon: 'times-circle' };
|
||||||
|
case '10':
|
||||||
|
return { label: 'Em Viagem', icon: 'ship' };
|
||||||
|
case '99':
|
||||||
|
return { label: 'Viagem Realizada', icon: 'ship' };
|
||||||
|
case '-2':
|
||||||
|
return { label: 'Pendente de confirmação com Agente', icon: 'clock-o' };
|
||||||
|
case '-3':
|
||||||
|
return { label: 'Pendente de Confirmação', icon: 'clock-o' };
|
||||||
|
case '-1':
|
||||||
|
return { label: 'Aguarda pagamento inicial', icon: 'clock-o' };
|
||||||
|
default:
|
||||||
|
return { label: 'Não definido', icon: 'question-circle' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusBadge({ statusCode }: Props) {
|
||||||
|
const type = getStatusType(statusCode);
|
||||||
|
const config = getStatusConfig(statusCode);
|
||||||
|
const color = getStatusColor(type);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusBadge,
|
||||||
|
type === 'verde' && styles.statusSuccess,
|
||||||
|
type === 'vermelho' && styles.statusDanger,
|
||||||
|
type === 'roxo' && styles.statusInfo,
|
||||||
|
type === 'amarelo' && styles.statusPendente,
|
||||||
|
]}>
|
||||||
|
<View style={styles.statusBadgeContent}>
|
||||||
|
<FontAwesome name={config.icon} size={12} color={color} />
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.statusBadgeText,
|
||||||
|
type === 'verde' && styles.statusTextSuccess,
|
||||||
|
type === 'vermelho' && styles.statusTextDanger,
|
||||||
|
type === 'roxo' && styles.statusTextInfo,
|
||||||
|
type === 'amarelo' && styles.statusTextPendente,
|
||||||
|
]}>
|
||||||
|
{config.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
assets/components/reserva/ValorReservaCard.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import CircleCheckIcon from '@/assets/icons/circle-check-solid.svg';
|
||||||
|
import { colors } from '@/assets/styles/colors';
|
||||||
|
import { ReservaData } from '@/assets/types';
|
||||||
|
import styles from '@/styles/screens/reserva/detail.styles';
|
||||||
|
import { Text, View } from 'react-native';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
reserva: ReservaData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatEuro = (value: string) => {
|
||||||
|
const n = parseFloat(value || '0');
|
||||||
|
return `€${n.toLocaleString('pt-PT', {
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
})}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ValorReservaCard({ reserva }: Props) {
|
||||||
|
const total = parseFloat(reserva.precoTotalFinal || '0');
|
||||||
|
const pago = parseFloat(reserva.valorPago || '0');
|
||||||
|
const emFalta = parseFloat(reserva.valorAPagar || '0');
|
||||||
|
const isFullyPaid = emFalta <= 0;
|
||||||
|
const percent = isFullyPaid
|
||||||
|
? 100
|
||||||
|
: total > 0
|
||||||
|
? Math.min(100, Math.max(0, (pago / total) * 100))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.valorSection}>
|
||||||
|
<Text style={styles.sectionTitle}>Valor da Reserva</Text>
|
||||||
|
|
||||||
|
<View style={styles.valorInnerCard}>
|
||||||
|
<Text style={styles.valorAmount}>{formatEuro(reserva.precoTotalFinal)}</Text>
|
||||||
|
<Text style={styles.valorAmountLabel}>Valor Total</Text>
|
||||||
|
|
||||||
|
<Text style={styles.valorPagoLine}>Pago {formatEuro(reserva.valorPago)}</Text>
|
||||||
|
|
||||||
|
<View style={styles.valorProgressTrack}>
|
||||||
|
{percent > 0 && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.valorProgressFill,
|
||||||
|
{ width: `${percent}%`, minWidth: 14 },
|
||||||
|
]}>
|
||||||
|
<View style={styles.valorProgressDot} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[styles.valorFaltaRow, isFullyPaid && styles.valorFaltaRowPago]}>
|
||||||
|
{isFullyPaid ? (
|
||||||
|
<View style={styles.valorPagoStatus}>
|
||||||
|
<CircleCheckIcon width={18} height={18} fill={colors.success} />
|
||||||
|
<Text style={styles.valorFaltaLabel}>Pago</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={styles.valorFaltaLabel}>Em Falta</Text>
|
||||||
|
<View style={styles.valorFaltaBadge}>
|
||||||
|
<Text style={styles.valorFaltaBadgeText}>
|
||||||
|
{formatEuro(reserva.valorAPagar)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
assets/components/reserva/VooCard.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import PlaneArrivalIcon from '@/assets/icons/plane-arrival-solid-full.svg';
|
||||||
|
import PlaneDepartureIcon from '@/assets/icons/plane-departure-solid-full.svg';
|
||||||
|
import { colors } from '@/assets/styles/colors';
|
||||||
|
import { Escala, VooSegment } from '@/assets/types';
|
||||||
|
import styles from '@/styles/screens/reserva/detail.styles';
|
||||||
|
import { FontAwesome } from '@expo/vector-icons';
|
||||||
|
import { Alert, Image, Linking, Platform, Pressable, Text, View } from 'react-native';
|
||||||
|
import { formatDateLong } from './formatters';
|
||||||
|
|
||||||
|
type SegmentProps = {
|
||||||
|
voo: Escala | VooSegment;
|
||||||
|
tipo: 'ida' | 'volta';
|
||||||
|
isLast: boolean;
|
||||||
|
onMapaPress?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const planeIconProps = { width: 16, height: 16, fill: colors.vermelho } as const;
|
||||||
|
|
||||||
|
function abrirMapaAeroporto(lat: number, lng: number, nome: string) {
|
||||||
|
const url = Platform.OS === 'ios'
|
||||||
|
? `maps://?ll=${lat},${lng}&q=${encodeURIComponent(nome)}`
|
||||||
|
: `geo:${lat},${lng}?q=${encodeURIComponent(nome)}`;
|
||||||
|
|
||||||
|
Linking.openURL(url).catch(() =>
|
||||||
|
Alert.alert('Erro', 'Não foi possível abrir a aplicação de mapas.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VooSegmentCard({ voo, tipo }: Omit<SegmentProps, 'onMapaPress' | 'isLast'>) {
|
||||||
|
const legLabel = tipo === 'ida' ? 'DATA DE IDA' : 'DATA DE REGRESSO';
|
||||||
|
const LegPlaneIcon = tipo === 'ida' ? PlaneDepartureIcon : PlaneArrivalIcon;
|
||||||
|
|
||||||
|
const escala = voo as Escala;
|
||||||
|
const gps = escala.infoAeroportoDeparture?.gps;
|
||||||
|
const nomeAeroporto = escala.infoAeroportoDeparture?.name ?? voo.departureAirport ?? voo.departureAirportCode;
|
||||||
|
|
||||||
|
const handleMapaPress = () => {
|
||||||
|
if (gps?.lat != null && gps?.lng != null) {
|
||||||
|
abrirMapaAeroporto(Number(gps.lat), Number(gps.lng), nomeAeroporto);
|
||||||
|
} else {
|
||||||
|
Alert.alert('Sem coordenadas', 'Não há informação de localização para este aeroporto.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<View style={styles.vooRouteRow}>
|
||||||
|
<View style={styles.vooSideCol}>
|
||||||
|
<Text style={styles.vooCode}>{voo.departureAirportCode}</Text>
|
||||||
|
<Text style={styles.vooTime}>{voo.departureTime}</Text>
|
||||||
|
<Text style={styles.vooAirport}>
|
||||||
|
{voo.departureAirportCode}-{voo.departureAirport}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.vooMidCol}>
|
||||||
|
<Text style={styles.vooDuration}>{voo.flightTime}</Text>
|
||||||
|
<View style={styles.vooPathWrap}>
|
||||||
|
<View style={styles.vooPathLine} />
|
||||||
|
<View style={styles.vooPathIcon}>
|
||||||
|
<FontAwesome
|
||||||
|
name="plane"
|
||||||
|
size={16}
|
||||||
|
color={colors.cinza}
|
||||||
|
style={{ transform: [{ rotate: '45deg' }] }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[styles.vooSideCol, styles.vooSideColRight]}>
|
||||||
|
<Text style={[styles.vooCode, styles.vooCodeRight]}>{voo.arrivalAirportCode}</Text>
|
||||||
|
<Text style={[styles.vooTime, styles.vooTimeRight]}>{voo.arrivalTime}</Text>
|
||||||
|
<Text style={[styles.vooAirport, styles.vooAirportRight]}>
|
||||||
|
{voo.arrivalAirportCode}-{voo.arrivalAirport}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.vooInfoBox}>
|
||||||
|
<View style={styles.vooInfoColLeft}>
|
||||||
|
<Text style={styles.vooInfoDate}>{formatDateLong(voo.departureDate)}</Text>
|
||||||
|
<View style={styles.vooLegRow}>
|
||||||
|
<LegPlaneIcon {...planeIconProps} />
|
||||||
|
<Text style={styles.vooLegLabel}>{legLabel}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.vooInfoColRight}>
|
||||||
|
{!!voo.malaLabel && (
|
||||||
|
<Text style={styles.vooMetaLine}>
|
||||||
|
<Text style={styles.vooMetaLabel}>Bagagem: </Text>
|
||||||
|
<Text style={styles.vooMetaValue}>{voo.malaLabel}</Text>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{!!voo.class && (
|
||||||
|
<Text style={styles.vooMetaLine}>
|
||||||
|
<Text style={styles.vooMetaLabel}>Classe: </Text>
|
||||||
|
<Text style={styles.vooMetaValue}>{voo.class}</Text>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable style={styles.mapaBtn} onPress={handleMapaPress}>
|
||||||
|
<Text style={styles.mapaBtnText}>
|
||||||
|
Mapa {voo.departureAirportCode}
|
||||||
|
</Text>
|
||||||
|
<Image source={require('@/assets/icons/seta-up.png')} style={styles.mapaBtnIcon} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupProps = {
|
||||||
|
segmentos: (Escala | VooSegment)[];
|
||||||
|
tipo: 'ida' | 'volta';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VooCard({ segmentos, tipo }: GroupProps) {
|
||||||
|
if (!segmentos.length) return null;
|
||||||
|
const temEscalas = segmentos.length > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.vooCard}>
|
||||||
|
{temEscalas && (
|
||||||
|
<View style={styles.vooEscalasBadge}>
|
||||||
|
<FontAwesome name="random" size={14} color={colors.azul} />
|
||||||
|
<Text style={styles.vooEscalasBadgeText}>
|
||||||
|
{segmentos.length - 1} {segmentos.length - 1 === 1 ? 'escala' : 'escalas'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{segmentos.map((voo, idx) => (
|
||||||
|
<View key={`seg-${idx}`}>
|
||||||
|
<VooSegmentCard
|
||||||
|
voo={voo}
|
||||||
|
tipo={tipo}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{idx < segmentos.length - 1 && (
|
||||||
|
<View style={styles.vooEscalaDivider}>
|
||||||
|
<View style={styles.vooEscalaLine} />
|
||||||
|
<View style={styles.vooEscalaChip}>
|
||||||
|
<FontAwesome name="clock-o" size={10} color="#4A6592" />
|
||||||
|
<Text style={styles.vooEscalaChipText}>
|
||||||
|
Escala em {voo.arrivalAirportCode}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.vooEscalaLine} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
assets/components/reserva/formatters.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { API_BASE_URL } from '@/assets/config/api';
|
||||||
|
|
||||||
|
export const formatDateShort = (dateString: string) => {
|
||||||
|
if (!dateString) return '';
|
||||||
|
const d = new Date(dateString);
|
||||||
|
if (Number.isNaN(d.getTime())) return dateString;
|
||||||
|
return d.toLocaleDateString('pt-PT');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatDateLong = (dateString: string) => {
|
||||||
|
if (!dateString) return '';
|
||||||
|
const d = new Date(dateString);
|
||||||
|
if (Number.isNaN(d.getTime())) return dateString;
|
||||||
|
return d.toLocaleDateString('pt-PT', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatCurrency = (value: string) =>
|
||||||
|
`${parseFloat(value || '0').toFixed(0)} EUR`;
|
||||||
|
|
||||||
|
export const getImageUrl = (path?: string) => {
|
||||||
|
if (!path) return '';
|
||||||
|
if (path.startsWith('http')) return path;
|
||||||
|
const baseUrl = API_BASE_URL.replace(/\/pt\/app$/, '');
|
||||||
|
const imagePath = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
return `${baseUrl}${imagePath}`;
|
||||||
|
};
|
||||||
68
assets/config/api.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Configuração centralizada de API
|
||||||
|
*
|
||||||
|
* Este arquivo contém todas as configurações relacionadas aos endpoints da API.
|
||||||
|
* Para usar como template, basta modificar o BASE_URL e os endpoints conforme necessário.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Endpoint base da API
|
||||||
|
export const API_BASE_URL = "https://apmtests.webclientes.com/pt/app";
|
||||||
|
export const API_BASE_URL_DEV = "https://apmtests.webclientes.com/pt/app";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoints da aplicação
|
||||||
|
* Adicione novos endpoints aqui conforme necessário
|
||||||
|
*/
|
||||||
|
export const API_ENDPOINTS = {
|
||||||
|
// Autenticação
|
||||||
|
USER_LOGIN: "/UserLogin",
|
||||||
|
USER_LOGOUT: "/UserLogout",
|
||||||
|
UPDATE_PROFILE: "/UserEditProfile",
|
||||||
|
RECOVER_PASSWORD: "/recoverPassword",
|
||||||
|
CONFIRM_TOKEN: "/confirmToken",
|
||||||
|
RESET_PASSWORD: "/resetPassword",
|
||||||
|
|
||||||
|
// Utilizador
|
||||||
|
USER_RESERVAS: "/getUserReservas",
|
||||||
|
USER_INFO: "/UserInfo",
|
||||||
|
GET_RESERVA: "/getReserva",
|
||||||
|
|
||||||
|
// Contactos
|
||||||
|
CONTACTS: "/contacts",
|
||||||
|
|
||||||
|
// Documentos
|
||||||
|
GET_DOCUMENTOS_CHECKSUMS: "/getDocumentosChecksums",
|
||||||
|
|
||||||
|
// Conta
|
||||||
|
DELETE_ACCOUNT: "/deleteAccount",
|
||||||
|
|
||||||
|
// Adicione mais endpoints aqui conforme necessário
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constrói uma URL completa combinando o BASE_URL com o endpoint
|
||||||
|
*
|
||||||
|
* @param endpoint - O endpoint a ser adicionado ao BASE_URL (ex: "/UserLogin")
|
||||||
|
* @returns A URL completa
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* buildApiUrl(API_ENDPOINTS.USER_LOGIN)
|
||||||
|
* // Retorna: "https://finalguru.webclientes.com/pt/app/UserLogin"
|
||||||
|
*/
|
||||||
|
export function buildApiUrl(endpoint: string): string {
|
||||||
|
// Remove barras duplicadas e garante que há apenas uma barra entre base e endpoint
|
||||||
|
const base = API_BASE_URL.replace(/\/$/, "");
|
||||||
|
const path = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
|
||||||
|
return `${base}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configurações adicionais da API (timeout, headers padrão, etc.)
|
||||||
|
*/
|
||||||
|
export const API_CONFIG = {
|
||||||
|
TIMEOUT: 30000, // 30 segundos
|
||||||
|
DEFAULT_HEADERS: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
231
assets/contexts/useAuth.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import CryptoJS from 'crypto-js';
|
||||||
|
import { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
import { API_CONFIG, API_ENDPOINTS, buildApiUrl } from '../config/api';
|
||||||
|
import { clearDownloadedDocuments } from '../services/documentSync';
|
||||||
|
import { clearUserSessionCache } from '../services/offlineStorage';
|
||||||
|
import { AuthContextType, AuthProviderProps, LoginResponse, User, UserData } from '../types';
|
||||||
|
// Contexto
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
// Chaves para AsyncStorage
|
||||||
|
const STORAGE_KEYS = {
|
||||||
|
TOKEN: '@cruiseLovers:token',
|
||||||
|
USER: '@cruiseLovers:user',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Provider
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: AuthProviderProps) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [userData, setUserData] = useState<UserData | null>(null);
|
||||||
|
// Carregar dados salvos ao iniciar
|
||||||
|
useEffect(() => {
|
||||||
|
loadStoredAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadStoredAuth = async () => {
|
||||||
|
try {
|
||||||
|
const [storedToken, storedUser] = await Promise.all([
|
||||||
|
AsyncStorage.getItem(STORAGE_KEYS.TOKEN),
|
||||||
|
AsyncStorage.getItem(STORAGE_KEYS.USER),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (storedToken && storedUser) {
|
||||||
|
setToken(storedToken);
|
||||||
|
setUser(JSON.parse(storedUser));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao carregar autenticação:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const login = async (email: string, password: string) => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const url = buildApiUrl(API_ENDPOINTS.USER_LOGIN);
|
||||||
|
|
||||||
|
// Encriptar password com MD5
|
||||||
|
const encryptedPassword = CryptoJS.MD5(password).toString();
|
||||||
|
|
||||||
|
// Criar FormData com os dados de login
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('email', email);
|
||||||
|
formData.append('password', encryptedPassword);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
// Não definir Content-Type - o React Native define automaticamente para multipart/form-data
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: LoginResponse = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || data.status !== 200) {
|
||||||
|
// A API retorna status 401 com mensagem quando não existe utilizador
|
||||||
|
throw new Error(data.message || 'Erro ao fazer login');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limpar dados do utilizador anterior antes de guardar a nova sessão
|
||||||
|
await clearUserSessionCache();
|
||||||
|
await clearDownloadedDocuments();
|
||||||
|
|
||||||
|
// Se a resposta for bem-sucedida, Guardar token e user
|
||||||
|
if (data.token) {
|
||||||
|
setToken(data.token);
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEYS.TOKEN, data.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.user) {
|
||||||
|
setUser(data.user);
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(data.user));
|
||||||
|
} else {
|
||||||
|
// Se não vier user na resposta, criar um objeto básico com email
|
||||||
|
setUser(userData);
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(userData));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Erro desconhecido ao fazer login';
|
||||||
|
setError(errorMessage);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateProfile = async (nome?: string, apelido?: string, oldPassword?: string, newPassword?: string) => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Não autenticado');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = buildApiUrl(API_ENDPOINTS.UPDATE_PROFILE);
|
||||||
|
// Criar FormData com os dados de atualização
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
formData.append('token', token);
|
||||||
|
|
||||||
|
if (nome) {
|
||||||
|
formData.append('nome', nome);
|
||||||
|
}
|
||||||
|
if (apelido) {
|
||||||
|
formData.append('apelido', apelido);
|
||||||
|
}
|
||||||
|
if (newPassword) {
|
||||||
|
const encryptedNewPassword = CryptoJS.MD5(newPassword).toString();
|
||||||
|
formData.append('password', encryptedNewPassword);
|
||||||
|
if (oldPassword) {
|
||||||
|
const encryptedOldPassword = CryptoJS.MD5(oldPassword).toString();
|
||||||
|
formData.append('old_password', encryptedOldPassword);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: LoginResponse = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || data.status !== 200) {
|
||||||
|
throw new Error(data.message || 'Erro ao atualizar perfil');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atualizar dados do utilizador se vierem na resposta
|
||||||
|
if (data.user) {
|
||||||
|
setUser(data.user);
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(data.user));
|
||||||
|
} else if (nome || apelido) {
|
||||||
|
const updatedUser = { ...user, nome: nome ?? user?.nome, apelido: apelido ?? user?.apelido };
|
||||||
|
setUser(updatedUser as User);
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(updatedUser));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Erro desconhecido ao atualizar perfil';
|
||||||
|
setError(errorMessage);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// Opcional: chamar endpoint de logout na API
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
const url = buildApiUrl(API_ENDPOINTS.USER_LOGOUT);
|
||||||
|
await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...API_CONFIG.DEFAULT_HEADERS,
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao fazer logout na API:', err);
|
||||||
|
// Continuar mesmo se falhar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limpar estado local
|
||||||
|
setUser(null);
|
||||||
|
setToken(null);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
await clearUserSessionCache();
|
||||||
|
await clearDownloadedDocuments();
|
||||||
|
|
||||||
|
// Limpar credenciais de sessão
|
||||||
|
await Promise.all([
|
||||||
|
AsyncStorage.removeItem(STORAGE_KEYS.TOKEN),
|
||||||
|
AsyncStorage.removeItem(STORAGE_KEYS.USER),
|
||||||
|
]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao fazer logout:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const value: AuthContextType = {
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
isAuthenticated: !!user && !!token,
|
||||||
|
isLoading,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
updateProfile,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook para usar o contexto
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth deve ser usado dentro de um AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
BIN
assets/fonts/SpaceGrotesk-Bold.ttf
Normal file
BIN
assets/fonts/SpaceGrotesk-Light.ttf
Normal file
BIN
assets/fonts/SpaceGrotesk-Medium.ttf
Normal file
BIN
assets/fonts/SpaceGrotesk-Regular.ttf
Normal file
BIN
assets/fonts/SpaceGrotesk-SemiBold.ttf
Normal file
28
assets/icons/StatusBadgeIcon.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import CircleCheckIcon from '@/assets/icons/circle-check-solid.svg';
|
||||||
|
import CircleXmarkIcon from '@/assets/icons/circle-xmark-solid.svg';
|
||||||
|
import ClockIcon from '@/assets/icons/clock-solid.svg';
|
||||||
|
import ShipIcon from '@/assets/icons/ship-solid.svg';
|
||||||
|
import { SvgProps } from 'react-native-svg';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
statusType: string;
|
||||||
|
color: string;
|
||||||
|
size?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusBadgeIcon({ statusType, color, size = 10 }: Props) {
|
||||||
|
const iconProps: SvgProps = { width: size, height: size, fill: color };
|
||||||
|
|
||||||
|
switch (statusType) {
|
||||||
|
case 'verde':
|
||||||
|
return <CircleCheckIcon {...iconProps} />;
|
||||||
|
case 'vermelho':
|
||||||
|
return <CircleXmarkIcon {...iconProps} />;
|
||||||
|
case 'amarelo':
|
||||||
|
return <ClockIcon {...iconProps} />;
|
||||||
|
case 'roxo':
|
||||||
|
return <ShipIcon {...iconProps} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
assets/icons/calendario.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
1
assets/icons/chevron-right.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M471.1 297.4C483.6 309.9 483.6 330.2 471.1 342.7L279.1 534.7C266.6 547.2 246.3 547.2 233.8 534.7C221.3 522.2 221.3 501.9 233.8 489.4L403.2 320L233.9 150.6C221.4 138.1 221.4 117.8 233.9 105.3C246.4 92.8 266.7 92.8 279.2 105.3L471.2 297.3z"/></svg>
|
||||||
|
After Width: | Height: | Size: 468 B |
1
assets/icons/circle-check-solid.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM369 209L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"/></svg>
|
||||||
|
After Width: | Height: | Size: 424 B |
1
assets/icons/circle-xmark-solid.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM175 175c9.4-9.4 24.6-9.4 33.9 0l47 47 47-47c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-47 47 47 47c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-47-47-47 47c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l47-47-47-47c-9.4-9.4-9.4-24.6 0-33.9z"/></svg>
|
||||||
|
After Width: | Height: | Size: 511 B |
1
assets/icons/clock-solid.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M256 0a256 256 0 1 1 0 512A256 256 0 1 1 256 0zM232 120l0 136c0 8 4 15.5 10.7 20l96 64c11 7.4 25.9 4.4 33.3-6.7s4.4-25.9-6.7-33.3L280 243.2 280 120c0-13.3-10.7-24-24-24s-24 10.7-24 24z"/></svg>
|
||||||
|
After Width: | Height: | Size: 415 B |
BIN
assets/icons/confirmadas.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
assets/icons/contactos-fill.png
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
assets/icons/contactos.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/icons/email.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/icons/home-selecionado.png
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
assets/icons/home.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
assets/icons/logo.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
assets/icons/logotipo-azul.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
assets/icons/logotipo-branco.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
assets/icons/logout-fill.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
assets/icons/mail.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
assets/icons/mala.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
assets/icons/perfil-apagar-fill.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/icons/perfil-fill.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
assets/icons/perfil-selecionado.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/icons/perfil.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
1
assets/icons/plane-arrival-solid-full.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M418.6 257.1L297.9 67.7C293.1 60.1 285.3 54.9 276.5 53.4L233.4 45.8C222.6 43.9 213.2 53.1 214.8 63.9L238.8 225.5L133.8 207L100 145.2C96.5 138.7 90.2 134.2 83.1 133L66 130C56.2 128.3 47.2 135.9 47.2 145.8L47.8 252.1C48 283 70.2 309.4 100.7 314.8L114.2 317.2L114.2 317.2L531.8 390.8C562.3 396.2 591.3 375.8 596.7 345.4C602.1 315 581.7 285.9 551.3 280.5L418.6 257.1zM256 448C273.7 448 288 433.7 288 416C288 398.3 273.7 384 256 384C238.3 384 224 398.3 224 416C224 433.7 238.3 448 256 448zM387.2 432.7C387.2 415 372.9 400.7 355.2 400.7C337.5 400.7 323.2 415 323.2 432.7C323.2 450.4 337.5 464.7 355.2 464.7C372.9 464.7 387.2 450.4 387.2 432.7zM64 512C46.3 512 32 526.3 32 544C32 561.7 46.3 576 64 576L576 576C593.7 576 608 561.7 608 544C608 526.3 593.7 512 576 512L64 512z"/></svg>
|
||||||
|
After Width: | Height: | Size: 997 B |
1
assets/icons/plane-departure-solid-full.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M404 207.9L204.7 104.2C196.7 100.1 187.4 99.4 179 102.5L137.9 117.5C127.6 121.2 124.1 133.9 130.8 142.5L232.3 270.4L132.1 306.8L72 270.2C65.8 266.4 58.2 265.7 51.3 268.1L35 274.1C25.6 277.5 21.6 288.6 26.7 297.2L80.3 389C95.9 415.7 128.4 427.4 157.4 416.8L170.3 412.1L170.3 412.1L568.7 267.1C597.8 256.5 612.7 224.4 602.2 195.3C591.7 166.2 559.5 151.3 530.4 161.8L404 207.9zM64.2 512C46.5 512 32.2 526.3 32.2 544C32.2 561.7 46.5 576 64.2 576L576.2 576C593.9 576 608.2 561.7 608.2 544C608.2 526.3 593.9 512 576.2 512L64.2 512z"/></svg>
|
||||||
|
After Width: | Height: | Size: 756 B |
1
assets/icons/right-from-bracket-solid-full.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M569 337C578.4 327.6 578.4 312.4 569 303.1L425 159C418.1 152.1 407.8 150.1 398.8 153.8C389.8 157.5 384 166.3 384 176L384 256L272 256C245.5 256 224 277.5 224 304L224 336C224 362.5 245.5 384 272 384L384 384L384 464C384 473.7 389.8 482.5 398.8 486.2C407.8 489.9 418.1 487.9 425 481L569 337zM224 160C241.7 160 256 145.7 256 128C256 110.3 241.7 96 224 96L160 96C107 96 64 139 64 192L64 448C64 501 107 544 160 544L224 544C241.7 544 256 529.7 256 512C256 494.3 241.7 480 224 480L160 480C142.3 480 128 465.7 128 448L128 192C128 174.3 142.3 160 160 160L224 160z"/></svg>
|
||||||
|
After Width: | Height: | Size: 783 B |
BIN
assets/icons/seta-up-fill.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
assets/icons/seta-up.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
1
assets/icons/ship-solid.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M192 32c0-17.7 14.3-32 32-32L352 0c17.7 0 32 14.3 32 32l0 32 48 0c26.5 0 48 21.5 48 48l0 128 44.4 14.8c23.1 7.7 29.5 37.5 11.5 53.9l-101 92.6c-16.2 9.4-34.7 15.1-50.9 15.1c-19.6 0-40.8-7.7-59.2-20.3c-22.1-15.5-51.6-15.5-73.7 0c-17.1 11.8-38 20.3-59.2 20.3c-16.2 0-34.7-5.7-50.9-15.1l-101-92.6c-18-16.5-11.6-46.2 11.5-53.9L96 240l0-128c0-26.5 21.5-48 48-48l48 0 0-32zM160 218.7l107.8-35.9c13.1-4.4 27.3-4.4 40.5 0L416 218.7l0-90.7-256 0 0 90.7zM306.5 421.9C329 437.4 356.5 448 384 448c26.9 0 55.4-10.8 77.4-26.1c0 0 0 0 0 0c11.9-8.5 28.1-7.8 39.2 1.7c14.4 11.9 32.5 21 50.6 25.2c17.2 4 27.9 21.2 23.9 38.4s-21.2 27.9-38.4 23.9c-24.5-5.7-44.9-16.5-58.2-25C449.5 501.7 417 512 384 512c-31.9 0-60.6-9.9-80.4-18.9c-5.8-2.7-11.1-5.3-15.6-7.7c-4.5 2.4-9.7 5.1-15.6 7.7c-19.8 9-48.5 18.9-80.4 18.9c-33 0-65.5-10.3-94.5-25.8c-13.4 8.4-33.7 19.3-58.2 25c-17.2 4-34.4-6.7-38.4-23.9s6.7-34.4 23.9-38.4c18.1-4.2 36.2-13.3 50.6-25.2c11.1-9.4 27.3-10.1 39.2-1.7c0 0 0 0 0 0C136.7 437.2 165.1 448 192 448c27.5 0 55-10.6 77.5-26.1c11.1-7.9 25.9-7.9 37 0z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
BIN
assets/icons/telefone.png
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
1
assets/icons/user-solid-full.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M320 312C386.3 312 440 258.3 440 192C440 125.7 386.3 72 320 72C253.7 72 200 125.7 200 192C200 258.3 253.7 312 320 312zM290.3 368C191.8 368 112 447.8 112 546.3C112 562.7 125.3 576 141.7 576L498.3 576C514.7 576 528 562.7 528 546.3C528 447.8 448.2 368 349.7 368L290.3 368z"/></svg>
|
||||||
|
After Width: | Height: | Size: 500 B |
1
assets/icons/user-xmark-solid-full.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M286.1 368C384.6 368 464.4 447.8 464.4 546.3C464.4 562.7 451.1 576 434.7 576L78.1 576C61.7 576 48.4 562.7 48.4 546.3C48.4 447.8 128.2 368 226.7 368L286.1 368zM562.3 172.1C571.7 162.7 586.9 162.7 596.2 172.1C605.5 181.5 605.6 196.7 596.2 206L562.3 239.9L596.2 273.8C605.6 283.2 605.6 298.4 596.2 307.7C586.8 317 571.6 317.1 562.3 307.7L528.4 273.8L494.5 307.7C485.1 317.1 469.9 317.1 460.6 307.7C451.3 298.3 451.2 283.1 460.6 273.8L494.5 239.9L460.6 206C451.2 196.6 451.2 181.4 460.6 172.1C470 162.8 485.2 162.7 494.5 172.1L528.4 206L562.3 172.1zM256.4 312C190.1 312 136.4 258.3 136.4 192C136.4 125.7 190.1 72 256.4 72C322.7 72 376.4 125.7 376.4 192C376.4 258.3 322.7 312 256.4 312z"/></svg>
|
||||||
|
After Width: | Height: | Size: 912 B |
BIN
assets/icons/whatsapp.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
BIN
assets/images/banner-login.png
Normal file
|
After Width: | Height: | Size: 660 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 384 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 17 KiB |
163
assets/services/documentSync.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { buildApiUrl, API_BASE_URL, API_ENDPOINTS } from "../config/api";
|
||||||
|
import { DocumentoChecksum, DocumentosChecksumsResponse, ReservaDocumentos } from "../types";
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import * as FileSystem from "expo-file-system/legacy";
|
||||||
|
|
||||||
|
const DOCUMENTOS_DIR = `${FileSystem.documentDirectory}documentos/`;
|
||||||
|
|
||||||
|
// Devolve o caminho local de um documento
|
||||||
|
const getLocalPath = (idDocumento: string, caminhoFicheiro: string): string => {
|
||||||
|
const fileName = caminhoFicheiro.split("/").pop() || `documento_${idDocumento}.pdf`;
|
||||||
|
return `${DOCUMENTOS_DIR}${idDocumento}_${fileName}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Garante que o diretório de documentos existe
|
||||||
|
const ensureDir = async (): Promise<void> => {
|
||||||
|
const info = await FileSystem.getInfoAsync(DOCUMENTOS_DIR);
|
||||||
|
if (!info.exists) {
|
||||||
|
await FileSystem.makeDirectoryAsync(DOCUMENTOS_DIR, { intermediates: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Constrói URL completa a partir de um caminho relativo
|
||||||
|
const buildUrl = (path: string | null | undefined): string => {
|
||||||
|
if (!path) return "";
|
||||||
|
if (path.startsWith("http")) return path;
|
||||||
|
const base = API_BASE_URL.replace(/\/pt\/app$/, "");
|
||||||
|
return `${base}${path.startsWith("/") ? path : `/${path}`}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Checksum guardado localmente para um documento
|
||||||
|
export const getStoredChecksum = async (idDocumento: string): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
return await AsyncStorage.getItem(`@cruiseLovers:documento:${idDocumento}:checksum`);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Guarda o checksum localmente
|
||||||
|
export const saveStoredChecksum = async (idDocumento: string, checksum: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(`@cruiseLovers:documento:${idDocumento}:checksum`, checksum);
|
||||||
|
} catch {
|
||||||
|
// silencioso
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Devolve o URI local de um documento se este já estiver descarregado, ou null caso contrário.
|
||||||
|
*/
|
||||||
|
export const getLocalDocumentUri = async (idDocumento: string, caminhoFicheiro: string): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const localPath = getLocalPath(idDocumento, caminhoFicheiro);
|
||||||
|
const info = await FileSystem.getInfoAsync(localPath);
|
||||||
|
return info.exists ? localPath : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Faz download de um único documento
|
||||||
|
export const downloadDocumento = async (documento: DocumentoChecksum): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const url = buildUrl(documento.caminhoFicheiro);
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
await ensureDir();
|
||||||
|
|
||||||
|
const localPath = getLocalPath(documento.idDocumento, documento.caminhoFicheiro);
|
||||||
|
|
||||||
|
const result = await FileSystem.downloadAsync(url, localPath);
|
||||||
|
|
||||||
|
if (result.status < 200 || result.status >= 300) {
|
||||||
|
console.error(`Download falhou com status ${result.status}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (documento.checksum) {
|
||||||
|
await saveStoredChecksum(documento.idDocumento, documento.checksum);
|
||||||
|
}
|
||||||
|
|
||||||
|
return localPath;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao fazer download:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Busca checksums do servidor
|
||||||
|
export const getDocumentosChecksums = async (token: string): Promise<DocumentosChecksumsResponse | null> => {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("token", token);
|
||||||
|
|
||||||
|
const response = await fetch(buildApiUrl(API_ENDPOINTS.GET_DOCUMENTOS_CHECKSUMS), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (await response.json()) as DocumentosChecksumsResponse;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao buscar checksums de documentos:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verifica quais documentos precisam ser (re)descarregados
|
||||||
|
export const verificarDocumentosParaDownload = async (
|
||||||
|
reservaDocumentos: ReservaDocumentos[]
|
||||||
|
): Promise<DocumentoChecksum[]> => {
|
||||||
|
const para: DocumentoChecksum[] = [];
|
||||||
|
|
||||||
|
for (const reserva of reservaDocumentos) {
|
||||||
|
for (const doc of reserva.documentos) {
|
||||||
|
if (!doc.caminhoFicheiro || !doc.checksum) continue;
|
||||||
|
|
||||||
|
const storedChecksum = await getStoredChecksum(doc.idDocumento);
|
||||||
|
const localPath = getLocalPath(doc.idDocumento, doc.caminhoFicheiro);
|
||||||
|
const info = await FileSystem.getInfoAsync(localPath);
|
||||||
|
|
||||||
|
if (doc.checksum !== storedChecksum || !info.exists) {
|
||||||
|
para.push(doc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return para;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Remove documentos descarregados e checksums (ex.: no logout). */
|
||||||
|
export const clearDownloadedDocuments = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const keys = await AsyncStorage.getAllKeys();
|
||||||
|
const docKeys = keys.filter((key) => key.startsWith("@cruiseLovers:documento:"));
|
||||||
|
if (docKeys.length > 0) {
|
||||||
|
await AsyncStorage.multiRemove(docKeys);
|
||||||
|
}
|
||||||
|
const info = await FileSystem.getInfoAsync(DOCUMENTOS_DIR);
|
||||||
|
if (info.exists) {
|
||||||
|
await FileSystem.deleteAsync(DOCUMENTOS_DIR, { idempotent: true });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao limpar documentos locais:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Descarrega múltiplos documentos
|
||||||
|
export const downloadDocumentos = async (
|
||||||
|
documentos: DocumentoChecksum[],
|
||||||
|
onProgress?: (current: number, total: number) => void
|
||||||
|
): Promise<{ sucesso: number; falhas: number }> => {
|
||||||
|
let sucesso = 0;
|
||||||
|
let falhas = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < documentos.length; i++) {
|
||||||
|
if (onProgress) onProgress(i + 1, documentos.length);
|
||||||
|
const resultado = await downloadDocumento(documentos[i]);
|
||||||
|
resultado ? sucesso++ : falhas++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sucesso, falhas };
|
||||||
|
};
|
||||||
247
assets/services/offlineStorage.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import { ContactData, Documento, Pagamento, Reserva, ReservaData, SocialMedia } from "../types";
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
|
||||||
|
const STORAGE_KEYS = {
|
||||||
|
RESERVAS: "@cruiseLovers:reservas",
|
||||||
|
RESERVA_DETAIL: "@cruiseLovers:reserva:",
|
||||||
|
RESERVA_FULL: "@cruiseLovers:reservaFull:",
|
||||||
|
CONTACTS: "@cruiseLovers:contacts",
|
||||||
|
SOCIALS: "@cruiseLovers:socials",
|
||||||
|
USER_INFO: "@cruiseLovers:userInfo",
|
||||||
|
LAST_SYNC: "@cruiseLovers:lastSync",
|
||||||
|
CACHE_VERSION: "@cruiseLovers:cacheVersion",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface CachedReservaFull {
|
||||||
|
reservaData: ReservaData;
|
||||||
|
pagamentos: Pagamento[];
|
||||||
|
documentos: Documento[];
|
||||||
|
cachedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CACHE_VERSION = "1.0.0";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Armazena a lista de reservas no cache
|
||||||
|
*/
|
||||||
|
export const cacheReservas = async (reservas: Reserva[]): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEYS.RESERVAS, JSON.stringify(reservas));
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEYS.LAST_SYNC, new Date().toISOString());
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao armazenar reservas no cache:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém a lista de reservas do cache
|
||||||
|
*/
|
||||||
|
export const getCachedReservas = async (): Promise<Reserva[] | null> => {
|
||||||
|
try {
|
||||||
|
const cached = await AsyncStorage.getItem(STORAGE_KEYS.RESERVAS);
|
||||||
|
if (cached) {
|
||||||
|
return JSON.parse(cached);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao obter reservas do cache:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Armazena os detalhes de uma reserva no cache
|
||||||
|
*/
|
||||||
|
export const cacheReservaDetail = async (id: string, reservaData: ReservaData): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const key = `${STORAGE_KEYS.RESERVA_DETAIL}${id}`;
|
||||||
|
await AsyncStorage.setItem(key, JSON.stringify(reservaData));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Erro ao armazenar detalhes da reserva ${id} no cache:`, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém os detalhes de uma reserva do cache
|
||||||
|
*/
|
||||||
|
export const getCachedReservaDetail = async (id: string): Promise<ReservaData | null> => {
|
||||||
|
try {
|
||||||
|
const key = `${STORAGE_KEYS.RESERVA_DETAIL}${id}`;
|
||||||
|
const cached = await AsyncStorage.getItem(key);
|
||||||
|
if (cached) {
|
||||||
|
return JSON.parse(cached);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Erro ao obter detalhes da reserva ${id} do cache:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Armazena os dados completos de uma reserva (reserva + pagamentos + documentos) no cache
|
||||||
|
*/
|
||||||
|
export const cacheReservaFull = async (referencia: string, data: CachedReservaFull): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const key = `${STORAGE_KEYS.RESERVA_FULL}${referencia}`;
|
||||||
|
await AsyncStorage.setItem(key, JSON.stringify(data));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Erro ao armazenar dados completos da reserva ${referencia} no cache:`, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém os dados completos de uma reserva do cache
|
||||||
|
*/
|
||||||
|
export const getCachedReservaFull = async (referencia: string): Promise<CachedReservaFull | null> => {
|
||||||
|
try {
|
||||||
|
const key = `${STORAGE_KEYS.RESERVA_FULL}${referencia}`;
|
||||||
|
const cached = await AsyncStorage.getItem(key);
|
||||||
|
return cached ? (JSON.parse(cached) as CachedReservaFull) : null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Erro ao obter dados completos da reserva ${referencia} do cache:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Armazena os contactos no cache
|
||||||
|
*/
|
||||||
|
export const cacheContacts = async (contactData: ContactData, socials: SocialMedia[]): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEYS.CONTACTS, JSON.stringify(contactData));
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEYS.SOCIALS, JSON.stringify(socials));
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEYS.LAST_SYNC, new Date().toISOString());
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao armazenar contactos no cache:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém os contactos do cache
|
||||||
|
*/
|
||||||
|
export const getCachedContacts = async (): Promise<{ contactData: ContactData | null; socials: SocialMedia[] }> => {
|
||||||
|
try {
|
||||||
|
const cachedContact = await AsyncStorage.getItem(STORAGE_KEYS.CONTACTS);
|
||||||
|
const cachedSocials = await AsyncStorage.getItem(STORAGE_KEYS.SOCIALS);
|
||||||
|
|
||||||
|
return {
|
||||||
|
contactData: cachedContact ? JSON.parse(cachedContact) : null,
|
||||||
|
socials: cachedSocials ? JSON.parse(cachedSocials) : [],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao obter contactos do cache:", error);
|
||||||
|
return { contactData: null, socials: [] };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém a data da última sincronização
|
||||||
|
*/
|
||||||
|
export const getLastSyncDate = async (): Promise<Date | null> => {
|
||||||
|
try {
|
||||||
|
const lastSync = await AsyncStorage.getItem(STORAGE_KEYS.LAST_SYNC);
|
||||||
|
return lastSync ? new Date(lastSync) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpa todo o cache
|
||||||
|
*/
|
||||||
|
export const clearCache = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const keys = await AsyncStorage.getAllKeys();
|
||||||
|
const appKeys = keys.filter(key => key.startsWith("@cruiseLovers:"));
|
||||||
|
await AsyncStorage.multiRemove(appKeys);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao limpar cache:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpa apenas o cache de reservas
|
||||||
|
*/
|
||||||
|
export const clearReservasCache = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const keys = await AsyncStorage.getAllKeys();
|
||||||
|
const reservaKeys = keys.filter(
|
||||||
|
(key) =>
|
||||||
|
key === STORAGE_KEYS.RESERVAS ||
|
||||||
|
key.startsWith(STORAGE_KEYS.RESERVA_DETAIL) ||
|
||||||
|
key.startsWith(STORAGE_KEYS.RESERVA_FULL),
|
||||||
|
);
|
||||||
|
await AsyncStorage.multiRemove(reservaKeys);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao limpar cache de reservas:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Dados de sessão do utilizador — mantém contactos da agência (partilhados). */
|
||||||
|
export const clearUserSessionCache = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const keys = await AsyncStorage.getAllKeys();
|
||||||
|
const sessionKeys = keys.filter(
|
||||||
|
(key) =>
|
||||||
|
key.startsWith("@cruiseLovers:") &&
|
||||||
|
key !== STORAGE_KEYS.CONTACTS &&
|
||||||
|
key !== STORAGE_KEYS.SOCIALS,
|
||||||
|
);
|
||||||
|
await AsyncStorage.multiRemove(sessionKeys);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao limpar cache da sessão:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Armazena informações do perfil no cache
|
||||||
|
*/
|
||||||
|
export const cacheUserInfo = async (userInfo: any): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEYS.USER_INFO, JSON.stringify(userInfo));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao armazenar perfil no cache:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém informações do perfil do cache
|
||||||
|
*/
|
||||||
|
export const getCachedUserInfo = async (): Promise<any | null> => {
|
||||||
|
try {
|
||||||
|
const cached = await AsyncStorage.getItem(STORAGE_KEYS.USER_INFO);
|
||||||
|
return cached ? JSON.parse(cached) : null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao obter perfil do cache:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém informações sobre o tamanho do cache
|
||||||
|
*/
|
||||||
|
export const getCacheInfo = async (): Promise<{ size: number; lastSync: Date | null }> => {
|
||||||
|
try {
|
||||||
|
const keys = await AsyncStorage.getAllKeys();
|
||||||
|
const appKeys = keys.filter(key => key.startsWith("@cruiseLovers:"));
|
||||||
|
const items = await AsyncStorage.multiGet(appKeys);
|
||||||
|
|
||||||
|
let totalSize = 0;
|
||||||
|
items.forEach(([_, value]) => {
|
||||||
|
if (value) {
|
||||||
|
totalSize += value.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastSync = await getLastSyncDate();
|
||||||
|
|
||||||
|
return {
|
||||||
|
size: totalSize,
|
||||||
|
lastSync,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao obter informações do cache:", error);
|
||||||
|
return { size: 0, lastSync: null };
|
||||||
|
}
|
||||||
|
};
|
||||||
50
assets/services/passwordRecovery.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { API_ENDPOINTS, buildApiUrl } from '@/assets/config/api';
|
||||||
|
import { ApiMessageResponse } from '@/assets/types';
|
||||||
|
import CryptoJS from 'crypto-js';
|
||||||
|
|
||||||
|
async function postToEndpoint(
|
||||||
|
endpoint: string,
|
||||||
|
fields: Record<string, string>,
|
||||||
|
): Promise<ApiMessageResponse> {
|
||||||
|
const formData = new FormData();
|
||||||
|
Object.entries(fields).forEach(([key, value]) => formData.append(key, value));
|
||||||
|
|
||||||
|
const response = await fetch(buildApiUrl(endpoint), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recoverPassword(email: string): Promise<ApiMessageResponse> {
|
||||||
|
return postToEndpoint(API_ENDPOINTS.RECOVER_PASSWORD, { email: email.trim() });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmRecoveryToken(
|
||||||
|
email: string,
|
||||||
|
token: string,
|
||||||
|
): Promise<ApiMessageResponse> {
|
||||||
|
return postToEndpoint(API_ENDPOINTS.CONFIRM_TOKEN, {
|
||||||
|
email: email.trim(),
|
||||||
|
token: token.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetPassword(
|
||||||
|
email: string,
|
||||||
|
token: string,
|
||||||
|
password: string,
|
||||||
|
confirmPassword: string,
|
||||||
|
): Promise<ApiMessageResponse> {
|
||||||
|
const encryptedPassword = CryptoJS.MD5(password).toString();
|
||||||
|
const encryptedConfirm = CryptoJS.MD5(confirmPassword).toString();
|
||||||
|
|
||||||
|
return postToEndpoint(API_ENDPOINTS.RESET_PASSWORD, {
|
||||||
|
email: email.trim(),
|
||||||
|
token: token.trim(),
|
||||||
|
password: encryptedPassword,
|
||||||
|
confirmPassword: encryptedConfirm,
|
||||||
|
});
|
||||||
|
}
|
||||||
31
assets/styles/colors.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { rgbaColor } from "react-native-reanimated/src/Colors";
|
||||||
|
|
||||||
|
export const colors = {
|
||||||
|
azul: '#123572',
|
||||||
|
azul_text: '#00235A',
|
||||||
|
azul_bg: 'rgba(0, 35, 90, 0.7)',
|
||||||
|
azul_escuro: 'rgba(0, 9, 25, 0.85)',
|
||||||
|
vermelho: '#EB2415',
|
||||||
|
|
||||||
|
background_1: '#F4F7FE',
|
||||||
|
background_2: '#EFEFEF',
|
||||||
|
|
||||||
|
cinza: '#C7C7C7',
|
||||||
|
|
||||||
|
branco: '#ffffff',
|
||||||
|
|
||||||
|
success: '#36E25D',
|
||||||
|
successAccent: '#D7FFE0',
|
||||||
|
|
||||||
|
pendente: '#CB36E2',
|
||||||
|
pendenteAccent: '#EB2415',
|
||||||
|
|
||||||
|
errorAccent: '#CB36E2',
|
||||||
|
error: '#F74A3D',
|
||||||
|
|
||||||
|
stars: '#F8D23A',
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
7
assets/styles/fonts.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const fonts = {
|
||||||
|
regular: 'SpaceGrotesk-Regular',
|
||||||
|
medium: 'SpaceGrotesk-Medium',
|
||||||
|
bold: 'SpaceGrotesk-Bold',
|
||||||
|
semiBold: 'SpaceGrotesk-SemiBold',
|
||||||
|
light: 'SpaceGrotesk-Light',
|
||||||
|
};
|
||||||
350
assets/types/index.tsx
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TIPOS DE AUTENTICAÇÃO
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
email: string;
|
||||||
|
nome: string;
|
||||||
|
apelido: string;
|
||||||
|
contacto: string;
|
||||||
|
morada: string;
|
||||||
|
nif: string;
|
||||||
|
id_user?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
token: string | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
updateProfile: (nome?: string, apelido?: string, oldPassword?: string, newPassword?: string) => Promise<void>;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
status: number;
|
||||||
|
message?: string;
|
||||||
|
token?: string;
|
||||||
|
user?: User;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiMessageResponse {
|
||||||
|
status: number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserData {
|
||||||
|
email: string;
|
||||||
|
nome: string;
|
||||||
|
apelido: string;
|
||||||
|
contacto: string;
|
||||||
|
morada: string;
|
||||||
|
nif: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TIPOS DE RESERVAS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface Reserva {
|
||||||
|
destino: string;
|
||||||
|
referenciaAgencia: string;
|
||||||
|
referenciaViagem: string;
|
||||||
|
startDate: string;
|
||||||
|
status: string;
|
||||||
|
statusCode: string;
|
||||||
|
imagemCidade: string;
|
||||||
|
pais: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReservasResponse {
|
||||||
|
status: string | number;
|
||||||
|
user?: User;
|
||||||
|
message?: string;
|
||||||
|
reservas: Reserva[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HotelFoto {
|
||||||
|
src: string;
|
||||||
|
srcZoom: string;
|
||||||
|
alt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Passageiro {
|
||||||
|
nome: string;
|
||||||
|
sobrenome: string;
|
||||||
|
genero: string;
|
||||||
|
dataNascimento: string;
|
||||||
|
nacionalidade: string;
|
||||||
|
morada: string;
|
||||||
|
paisEmissao: string;
|
||||||
|
numeroDocumento: string;
|
||||||
|
dataDeEmissao: string;
|
||||||
|
dataDeValidade: string;
|
||||||
|
telemovel: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HotelInfo {
|
||||||
|
name: string;
|
||||||
|
data: string;
|
||||||
|
noites: number;
|
||||||
|
regime: string;
|
||||||
|
quarto: string;
|
||||||
|
id_hotel: string;
|
||||||
|
imagemexterna: string;
|
||||||
|
imageminterna: string;
|
||||||
|
stars: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Escala {
|
||||||
|
companyCode: string;
|
||||||
|
company: string;
|
||||||
|
number: string;
|
||||||
|
departureAirportCode: string;
|
||||||
|
mapaAirportDeparture?: string;
|
||||||
|
departureAirport: string;
|
||||||
|
departureDateTime: string;
|
||||||
|
departureDate: string;
|
||||||
|
departureTime: string;
|
||||||
|
arrivalAirportCode: string;
|
||||||
|
mapaAirportArrival?: string;
|
||||||
|
arrivalAirport: string;
|
||||||
|
arrivalDateTime: string;
|
||||||
|
arrivalDate: string;
|
||||||
|
arrivalTime: string;
|
||||||
|
flightTime: string;
|
||||||
|
mala: boolean;
|
||||||
|
malaLabel: string;
|
||||||
|
class: string;
|
||||||
|
infoAeroportoDeparture?: InfoAeroporto;
|
||||||
|
infoAeroportoArrival?: InfoAeroporto;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VooSegment {
|
||||||
|
departureDate: string;
|
||||||
|
departureTime: string;
|
||||||
|
arrivalDate: string;
|
||||||
|
arrivalTime: string;
|
||||||
|
number: string;
|
||||||
|
class: string;
|
||||||
|
malaLabel: string;
|
||||||
|
departureAirportCode: string;
|
||||||
|
arrivalAirportCode: string;
|
||||||
|
departureAirport: string;
|
||||||
|
arrivalAirport: string;
|
||||||
|
departureAirportTimeZone: string;
|
||||||
|
arrivalAirportTimeZone: string;
|
||||||
|
flightTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VooLeg {
|
||||||
|
departureDate: string;
|
||||||
|
infoEscalas: Escala[];
|
||||||
|
infoAeroportoDeparture?: InfoAeroporto;
|
||||||
|
infoAeroportoArrival?: InfoAeroporto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InfoAeroporto {
|
||||||
|
name: string;
|
||||||
|
timezone: string;
|
||||||
|
iso_country?: string;
|
||||||
|
gps: {
|
||||||
|
lng: number | string;
|
||||||
|
lat: number | string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Extra {
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VooDirection = VooLeg | VooSegment[];
|
||||||
|
|
||||||
|
export interface JsonData {
|
||||||
|
hotel: HotelInfo[];
|
||||||
|
voo: {
|
||||||
|
departure: VooDirection;
|
||||||
|
arrival: VooDirection;
|
||||||
|
};
|
||||||
|
extra?: Extra[];
|
||||||
|
extras?: Extra[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Cidade {
|
||||||
|
id_cidade: string;
|
||||||
|
cod_pais: string;
|
||||||
|
imagem: string;
|
||||||
|
imagem_banner: string;
|
||||||
|
thumbnail: string;
|
||||||
|
destaque: string;
|
||||||
|
destaque_top: string;
|
||||||
|
outros_destinos: string;
|
||||||
|
lat: string;
|
||||||
|
lng: string;
|
||||||
|
zoom: string;
|
||||||
|
ordem: string;
|
||||||
|
reference: string;
|
||||||
|
ordemFooter: string;
|
||||||
|
html_image_social: string;
|
||||||
|
activofooter: string;
|
||||||
|
activo: string;
|
||||||
|
cod_tag: string;
|
||||||
|
categoria: string;
|
||||||
|
lowAeroporto: string;
|
||||||
|
lowData: string;
|
||||||
|
lowNoites: string;
|
||||||
|
lowRegimeKey: string;
|
||||||
|
lowPreco: string;
|
||||||
|
imagemMenu: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReservaData {
|
||||||
|
id_reserva: string;
|
||||||
|
destino: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
referenciaViagem: string;
|
||||||
|
referenciaAgencia: string;
|
||||||
|
localizador: string;
|
||||||
|
noites: string;
|
||||||
|
status: string;
|
||||||
|
pessoas: string;
|
||||||
|
adultos: string;
|
||||||
|
criancas: string;
|
||||||
|
quartos: string;
|
||||||
|
jsonData: string | JsonData;
|
||||||
|
precoTotalFinal: string;
|
||||||
|
valorPago: string;
|
||||||
|
valorAPagar: string;
|
||||||
|
operador: string;
|
||||||
|
linkTransfers?: string;
|
||||||
|
cidade: Cidade | null;
|
||||||
|
imagemCidade?: string;
|
||||||
|
infoCidade: string;
|
||||||
|
passageiros: Passageiro[];
|
||||||
|
quartosPassageiros?: QuartoPassageiro[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuartoPassageiro {
|
||||||
|
cod_user_api: string;
|
||||||
|
idade: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReservaResponse {
|
||||||
|
status: number;
|
||||||
|
message?: string;
|
||||||
|
reserva?: ReservaData;
|
||||||
|
pagamentos?: Pagamento[];
|
||||||
|
documentos?: Documento[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pagamento {
|
||||||
|
valor: string;
|
||||||
|
estado: string;
|
||||||
|
metodoPagamento: string;
|
||||||
|
data_hora: string;
|
||||||
|
data_pagamento: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Documento {
|
||||||
|
nome: string;
|
||||||
|
idDocumento: string;
|
||||||
|
caminhoFicheiro: string;
|
||||||
|
checksum: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TIPOS DE PERFIL
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface UserInfo {
|
||||||
|
nome: string;
|
||||||
|
apelido: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserInfoResponse {
|
||||||
|
status: number;
|
||||||
|
message?: string;
|
||||||
|
user?: UserInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TIPOS DE CONTACTOS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface ContactData {
|
||||||
|
email: string;
|
||||||
|
telephone: string;
|
||||||
|
mobilePhone: string;
|
||||||
|
address: string;
|
||||||
|
horarios: string;
|
||||||
|
whatsapp: string | null;
|
||||||
|
emergencyPhone: string | null;
|
||||||
|
coordenadas?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SocialMedia {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactResponse {
|
||||||
|
status: number;
|
||||||
|
message?: string;
|
||||||
|
contact?: ContactData;
|
||||||
|
socials?: SocialMedia[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TIPOS DE COMPONENTES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface CardDestinoProps {
|
||||||
|
destino: string;
|
||||||
|
referenciaAgencia: string;
|
||||||
|
referenciaViagem: string;
|
||||||
|
startDate: string;
|
||||||
|
status: string;
|
||||||
|
statusCode: string;
|
||||||
|
imagemCidade: string;
|
||||||
|
imageUrl: string;
|
||||||
|
color: string;
|
||||||
|
textColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TIPOS DE DOCUMENTOS CHECKSUMS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface DocumentoChecksum {
|
||||||
|
idDocumento: string;
|
||||||
|
checksum: string;
|
||||||
|
nome: string;
|
||||||
|
caminhoFicheiro: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReservaDocumentos {
|
||||||
|
referenciaViagem: string;
|
||||||
|
documentos: DocumentoChecksum[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentosChecksumsResponse {
|
||||||
|
status: number;
|
||||||
|
message?: string;
|
||||||
|
documentos: ReservaDocumentos[];
|
||||||
|
}
|
||||||
7
babel.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: ['babel-preset-expo'],
|
||||||
|
plugins: ['react-native-reanimated/plugin'],
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { Href, Link } from 'expo-router';
|
|
||||||
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
|
|
||||||
import { type ComponentProps } from 'react';
|
|
||||||
|
|
||||||
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
|
|
||||||
|
|
||||||
export function ExternalLink({ href, ...rest }: Props) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
target="_blank"
|
|
||||||
{...rest}
|
|
||||||
href={href}
|
|
||||||
onPress={async (event) => {
|
|
||||||
if (process.env.EXPO_OS !== 'web') {
|
|
||||||
// Prevent the default behavior of linking to the default browser on native.
|
|
||||||
event.preventDefault();
|
|
||||||
// Open the link in an in-app browser.
|
|
||||||
await openBrowserAsync(href, {
|
|
||||||
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
|
|
||||||
import { PlatformPressable } from '@react-navigation/elements';
|
|
||||||
import * as Haptics from 'expo-haptics';
|
|
||||||
|
|
||||||
export function HapticTab(props: BottomTabBarButtonProps) {
|
|
||||||
return (
|
|
||||||
<PlatformPressable
|
|
||||||
{...props}
|
|
||||||
onPressIn={(ev) => {
|
|
||||||
if (process.env.EXPO_OS === 'ios') {
|
|
||||||
// Add a soft haptic feedback when pressing down on the tabs.
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
}
|
|
||||||
props.onPressIn?.(ev);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import Animated from 'react-native-reanimated';
|
|
||||||
|
|
||||||
export function HelloWave() {
|
|
||||||
return (
|
|
||||||
<Animated.Text
|
|
||||||
style={{
|
|
||||||
fontSize: 28,
|
|
||||||
lineHeight: 32,
|
|
||||||
marginTop: -6,
|
|
||||||
animationName: {
|
|
||||||
'50%': { transform: [{ rotate: '25deg' }] },
|
|
||||||
},
|
|
||||||
animationIterationCount: 4,
|
|
||||||
animationDuration: '300ms',
|
|
||||||
}}>
|
|
||||||
👋
|
|
||||||
</Animated.Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import type { PropsWithChildren, ReactElement } from 'react';
|
|
||||||
import { StyleSheet } from 'react-native';
|
|
||||||
import Animated, {
|
|
||||||
interpolate,
|
|
||||||
useAnimatedRef,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useScrollOffset,
|
|
||||||
} from 'react-native-reanimated';
|
|
||||||
|
|
||||||
import { ThemedView } from '@/components/themed-view';
|
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
|
||||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
|
||||||
|
|
||||||
const HEADER_HEIGHT = 250;
|
|
||||||
|
|
||||||
type Props = PropsWithChildren<{
|
|
||||||
headerImage: ReactElement;
|
|
||||||
headerBackgroundColor: { dark: string; light: string };
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export default function ParallaxScrollView({
|
|
||||||
children,
|
|
||||||
headerImage,
|
|
||||||
headerBackgroundColor,
|
|
||||||
}: Props) {
|
|
||||||
const backgroundColor = useThemeColor({}, 'background');
|
|
||||||
const colorScheme = useColorScheme() ?? 'light';
|
|
||||||
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
|
||||||
const scrollOffset = useScrollOffset(scrollRef);
|
|
||||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
|
||||||
return {
|
|
||||||
transform: [
|
|
||||||
{
|
|
||||||
translateY: interpolate(
|
|
||||||
scrollOffset.value,
|
|
||||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
|
||||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Animated.ScrollView
|
|
||||||
ref={scrollRef}
|
|
||||||
style={{ backgroundColor, flex: 1 }}
|
|
||||||
scrollEventThrottle={16}>
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
styles.header,
|
|
||||||
{ backgroundColor: headerBackgroundColor[colorScheme] },
|
|
||||||
headerAnimatedStyle,
|
|
||||||
]}>
|
|
||||||
{headerImage}
|
|
||||||
</Animated.View>
|
|
||||||
<ThemedView style={styles.content}>{children}</ThemedView>
|
|
||||||
</Animated.ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
height: HEADER_HEIGHT,
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
flex: 1,
|
|
||||||
padding: 32,
|
|
||||||
gap: 16,
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { StyleSheet, Text, type TextProps } from 'react-native';
|
|
||||||
|
|
||||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
|
||||||
|
|
||||||
export type ThemedTextProps = TextProps & {
|
|
||||||
lightColor?: string;
|
|
||||||
darkColor?: string;
|
|
||||||
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ThemedText({
|
|
||||||
style,
|
|
||||||
lightColor,
|
|
||||||
darkColor,
|
|
||||||
type = 'default',
|
|
||||||
...rest
|
|
||||||
}: ThemedTextProps) {
|
|
||||||
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
{ color },
|
|
||||||
type === 'default' ? styles.default : undefined,
|
|
||||||
type === 'title' ? styles.title : undefined,
|
|
||||||
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
|
|
||||||
type === 'subtitle' ? styles.subtitle : undefined,
|
|
||||||
type === 'link' ? styles.link : undefined,
|
|
||||||
style,
|
|
||||||
]}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
default: {
|
|
||||||
fontSize: 16,
|
|
||||||
lineHeight: 24,
|
|
||||||
},
|
|
||||||
defaultSemiBold: {
|
|
||||||
fontSize: 16,
|
|
||||||
lineHeight: 24,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
lineHeight: 32,
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
link: {
|
|
||||||
lineHeight: 30,
|
|
||||||
fontSize: 16,
|
|
||||||
color: '#0a7ea4',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { View, type ViewProps } from 'react-native';
|
|
||||||
|
|
||||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
|
||||||
|
|
||||||
export type ThemedViewProps = ViewProps & {
|
|
||||||
lightColor?: string;
|
|
||||||
darkColor?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
|
|
||||||
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
|
|
||||||
|
|
||||||
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { PropsWithChildren, useState } from 'react';
|
|
||||||
import { StyleSheet, TouchableOpacity } from 'react-native';
|
|
||||||
|
|
||||||
import { ThemedText } from '@/components/themed-text';
|
|
||||||
import { ThemedView } from '@/components/themed-view';
|
|
||||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
|
||||||
import { Colors } from '@/constants/theme';
|
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
|
||||||
|
|
||||||
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const theme = useColorScheme() ?? 'light';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemedView>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.heading}
|
|
||||||
onPress={() => setIsOpen((value) => !value)}
|
|
||||||
activeOpacity={0.8}>
|
|
||||||
<IconSymbol
|
|
||||||
name="chevron.right"
|
|
||||||
size={18}
|
|
||||||
weight="medium"
|
|
||||||
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
|
|
||||||
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ThemedText type="defaultSemiBold">{title}</ThemedText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
|
|
||||||
</ThemedView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
heading: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
marginTop: 6,
|
|
||||||
marginLeft: 24,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
|
|
||||||
import { StyleProp, ViewStyle } from 'react-native';
|
|
||||||
|
|
||||||
export function IconSymbol({
|
|
||||||
name,
|
|
||||||
size = 24,
|
|
||||||
color,
|
|
||||||
style,
|
|
||||||
weight = 'regular',
|
|
||||||
}: {
|
|
||||||
name: SymbolViewProps['name'];
|
|
||||||
size?: number;
|
|
||||||
color: string;
|
|
||||||
style?: StyleProp<ViewStyle>;
|
|
||||||
weight?: SymbolWeight;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<SymbolView
|
|
||||||
weight={weight}
|
|
||||||
tintColor={color}
|
|
||||||
resizeMode="scaleAspectFit"
|
|
||||||
name={name}
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
},
|
|
||||||
style,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
// Fallback for using MaterialIcons on Android and web.
|
|
||||||
|
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
|
||||||
import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
|
|
||||||
import { ComponentProps } from 'react';
|
|
||||||
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
|
|
||||||
|
|
||||||
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
|
|
||||||
type IconSymbolName = keyof typeof MAPPING;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add your SF Symbols to Material Icons mappings here.
|
|
||||||
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
|
|
||||||
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
|
|
||||||
*/
|
|
||||||
const MAPPING = {
|
|
||||||
'house.fill': 'home',
|
|
||||||
'paperplane.fill': 'send',
|
|
||||||
'chevron.left.forwardslash.chevron.right': 'code',
|
|
||||||
'chevron.right': 'chevron-right',
|
|
||||||
} as IconMapping;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
|
|
||||||
* This ensures a consistent look across platforms, and optimal resource usage.
|
|
||||||
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
|
|
||||||
*/
|
|
||||||
export function IconSymbol({
|
|
||||||
name,
|
|
||||||
size = 24,
|
|
||||||
color,
|
|
||||||
style,
|
|
||||||
}: {
|
|
||||||
name: IconSymbolName;
|
|
||||||
size?: number;
|
|
||||||
color: string | OpaqueColorValue;
|
|
||||||
style?: StyleProp<TextStyle>;
|
|
||||||
weight?: SymbolWeight;
|
|
||||||
}) {
|
|
||||||
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
/**
|
|
||||||
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
|
|
||||||
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Platform } from 'react-native';
|
|
||||||
|
|
||||||
const tintColorLight = '#0a7ea4';
|
|
||||||
const tintColorDark = '#fff';
|
|
||||||
|
|
||||||
export const Colors = {
|
|
||||||
light: {
|
|
||||||
text: '#11181C',
|
|
||||||
background: '#fff',
|
|
||||||
tint: tintColorLight,
|
|
||||||
icon: '#687076',
|
|
||||||
tabIconDefault: '#687076',
|
|
||||||
tabIconSelected: tintColorLight,
|
|
||||||
},
|
|
||||||
dark: {
|
|
||||||
text: '#ECEDEE',
|
|
||||||
background: '#151718',
|
|
||||||
tint: tintColorDark,
|
|
||||||
icon: '#9BA1A6',
|
|
||||||
tabIconDefault: '#9BA1A6',
|
|
||||||
tabIconSelected: tintColorDark,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Fonts = Platform.select({
|
|
||||||
ios: {
|
|
||||||
/** iOS `UIFontDescriptorSystemDesignDefault` */
|
|
||||||
sans: 'system-ui',
|
|
||||||
/** iOS `UIFontDescriptorSystemDesignSerif` */
|
|
||||||
serif: 'ui-serif',
|
|
||||||
/** iOS `UIFontDescriptorSystemDesignRounded` */
|
|
||||||
rounded: 'ui-rounded',
|
|
||||||
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
|
|
||||||
mono: 'ui-monospace',
|
|
||||||
},
|
|
||||||
default: {
|
|
||||||
sans: 'normal',
|
|
||||||
serif: 'serif',
|
|
||||||
rounded: 'normal',
|
|
||||||
mono: 'monospace',
|
|
||||||
},
|
|
||||||
web: {
|
|
||||||
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
|
||||||
serif: "Georgia, 'Times New Roman', serif",
|
|
||||||
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
|
|
||||||
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { useColorScheme } from 'react-native';
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useColorScheme as useRNColorScheme } from 'react-native';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* To support static rendering, this value needs to be re-calculated on the client side for web
|
|
||||||
*/
|
|
||||||
export function useColorScheme() {
|
|
||||||
const [hasHydrated, setHasHydrated] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setHasHydrated(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const colorScheme = useRNColorScheme();
|
|
||||||
|
|
||||||
if (hasHydrated) {
|
|
||||||
return colorScheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'light';
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
/**
|
|
||||||
* Learn more about light and dark modes:
|
|
||||||
* https://docs.expo.dev/guides/color-schemes/
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Colors } from '@/constants/theme';
|
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
|
||||||
|
|
||||||
export function useThemeColor(
|
|
||||||
props: { light?: string; dark?: string },
|
|
||||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
|
||||||
) {
|
|
||||||
const theme = useColorScheme() ?? 'light';
|
|
||||||
const colorFromProps = props[theme];
|
|
||||||
|
|
||||||
if (colorFromProps) {
|
|
||||||
return colorFromProps;
|
|
||||||
} else {
|
|
||||||
return Colors[theme][colorName];
|
|
||||||
}
|
|
||||||
}
|
|
||||||