first commit
4
.env
Normal file
@@ -0,0 +1,4 @@
|
||||
# Supabase Configuration
|
||||
# Substitua com suas credenciais reais do Supabase
|
||||
EXPO_PUBLIC_SUPABASE_URL=your-supabase-project-url
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key
|
||||
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.pbxproj -text
|
||||
36
.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
20
App.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { AuthProvider } from './src/context/AuthContext';
|
||||
import AppNavigator from './src/navigation/AppNavigator';
|
||||
import { COLORS } from './src/constants/theme';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<AuthProvider>
|
||||
<StatusBar
|
||||
style="light"
|
||||
backgroundColor={COLORS.background}
|
||||
/>
|
||||
<AppNavigator />
|
||||
</AuthProvider>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
235
README.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# AppBarber - Premium Barber Shop Mobile App
|
||||
|
||||
A modern, premium barber shop booking and management application built with React Native and Expo.
|
||||
|
||||
## Features
|
||||
|
||||
### 🎯 Core Functionality
|
||||
- **Multi-Role System**: Customer, Barber, and Admin roles
|
||||
- **Appointment Booking**: Complete booking flow with availability checking
|
||||
- **Real-time Availability**: Prevents double bookings
|
||||
- **Service Management**: Browse and book various barber services
|
||||
- **Barber Profiles**: View barber ratings, specialties, and portfolios
|
||||
- **User Profiles**: Booking history and loyalty points system
|
||||
|
||||
### 🎨 Premium Design
|
||||
- **Dark Theme**: Luxury black and gold color palette
|
||||
- **Modern UI**: Clean, card-based interface
|
||||
- **Responsive Design**: Mobile-first approach
|
||||
- **Smooth Animations**: Premium user experience
|
||||
|
||||
### 🔐 Authentication
|
||||
- **Secure Login/Signup**: Email-based authentication
|
||||
- **Role-Based Access**: Different features for different user types
|
||||
- **Profile Management**: Users can update their information
|
||||
|
||||
### 📊 Admin Dashboard
|
||||
- **Booking Management**: View and manage all appointments
|
||||
- **Analytics**: Revenue statistics and daily appointments
|
||||
- **Staff Management**: Manage barbers and services
|
||||
- **Real-time Updates**: Live booking status management
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Frontend
|
||||
- **React Native** with **Expo**
|
||||
- **TypeScript** for type safety
|
||||
- **React Navigation** for navigation
|
||||
- **React Native Paper** for UI components
|
||||
- **Expo Linear Gradient** for premium styling
|
||||
|
||||
### Backend
|
||||
- **Supabase** for database and authentication
|
||||
- **PostgreSQL** database with Row Level Security
|
||||
- **Real-time subscriptions** for live updates
|
||||
|
||||
### Integrations
|
||||
- **Supabase Auth** for user authentication
|
||||
- **Cloudinary** for image uploads (configured)
|
||||
- **Stripe** for payments (configured)
|
||||
- **Expo Notifications** for push notifications
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ # Reusable UI components
|
||||
│ ├── ServiceCard.tsx
|
||||
│ ├── BarberCard.tsx
|
||||
│ ├── BookingCard.tsx
|
||||
│ └── ReviewCard.tsx
|
||||
├── screens/ # App screens
|
||||
│ ├── auth/ # Authentication screens
|
||||
│ ├── admin/ # Admin dashboard
|
||||
│ ├── HomeScreen.tsx
|
||||
│ ├── ServicesScreen.tsx
|
||||
│ ├── BarbersScreen.tsx
|
||||
│ ├── BookingScreen.tsx
|
||||
│ └── ProfileScreen.tsx
|
||||
├── navigation/ # Navigation configuration
|
||||
├── context/ # React Context
|
||||
├── services/ # API services
|
||||
├── constants/ # App constants and theme
|
||||
├── types/ # TypeScript type definitions
|
||||
└── utils/ # Utility functions
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Main Tables
|
||||
- **users**: User profiles and authentication
|
||||
- **barbers**: Barber-specific information
|
||||
- **services**: Available services and pricing
|
||||
- **bookings**: Appointment records
|
||||
- **reviews**: Customer reviews and ratings
|
||||
- **promotions**: Special offers and discounts
|
||||
|
||||
### Key Features
|
||||
- **Row Level Security**: Secure data access
|
||||
- **Real-time Updates**: Live booking status
|
||||
- **Foreign Key Constraints**: Data integrity
|
||||
- **Indexes**: Optimized queries
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 16+
|
||||
- Expo CLI
|
||||
- Supabase account
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd AppBarber
|
||||
```
|
||||
|
||||
2. **Install dependencies**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Set up environment variables**
|
||||
Create a `.env` file in the root directory:
|
||||
```env
|
||||
EXPO_PUBLIC_SUPABASE_URL=your_supabase_url
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||
```
|
||||
|
||||
4. **Set up Supabase database**
|
||||
- Create a new Supabase project
|
||||
- Run the SQL schema from `database/schema.sql`
|
||||
- Configure authentication settings
|
||||
- Set up Row Level Security policies
|
||||
|
||||
5. **Start the development server**
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
6. **Run on device/simulator**
|
||||
```bash
|
||||
npm run android # For Android
|
||||
npm run ios # For iOS
|
||||
npm run web # For web
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Supabase Setup
|
||||
1. Create a new project at [supabase.com](https://supabase.com)
|
||||
2. Run the provided SQL schema in the Supabase SQL editor
|
||||
3. Enable authentication providers
|
||||
4. Configure Row Level Security policies
|
||||
|
||||
### Environment Variables
|
||||
Copy `.env.example` to `.env` and fill in your credentials:
|
||||
- `EXPO_PUBLIC_SUPABASE_URL`: Your Supabase project URL
|
||||
- `EXPO_PUBLIC_SUPABASE_ANON_KEY`: Your Supabase anonymous key
|
||||
|
||||
## Key Features Implementation
|
||||
|
||||
### Booking Logic
|
||||
- **Availability Checking**: Prevents double bookings
|
||||
- **Time Slot Management**: 30-minute intervals from 9 AM to 6 PM
|
||||
- **Date Validation**: Prevents booking past dates
|
||||
- **Status Management**: Pending → Confirmed → Completed flow
|
||||
|
||||
### Authentication Flow
|
||||
- **Email/Password Login**: Secure authentication
|
||||
- **Role Assignment**: Customer, Barber, or Admin
|
||||
- **Profile Creation**: Automatic user profile creation
|
||||
- **Session Management**: Persistent login state
|
||||
|
||||
### Admin Features
|
||||
- **Dashboard Analytics**: Real-time statistics
|
||||
- **Booking Management**: Approve/cancel appointments
|
||||
- **Revenue Tracking**: Financial insights
|
||||
- **Staff Oversight**: Barber performance monitoring
|
||||
|
||||
## UI/UX Features
|
||||
|
||||
### Premium Design Elements
|
||||
- **Dark Theme**: Modern black and gold palette
|
||||
- **Card-Based Layout**: Clean, organized content
|
||||
- **Gradient Accents**: Premium visual effects
|
||||
- **Smooth Transitions**: Professional animations
|
||||
|
||||
### User Experience
|
||||
- **Intuitive Navigation**: Bottom tab navigation
|
||||
- **Progressive Disclosure**: Step-by-step booking
|
||||
- **Real-time Feedback**: Loading states and confirmations
|
||||
- **Error Handling**: User-friendly error messages
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Code Style
|
||||
- **TypeScript**: Strict typing throughout
|
||||
- **Component Architecture**: Reusable, modular components
|
||||
- **State Management**: React Context for global state
|
||||
- **Error Boundaries**: Graceful error handling
|
||||
|
||||
### Best Practices
|
||||
- **Performance**: Optimized re-renders and queries
|
||||
- **Security**: Row Level Security and input validation
|
||||
- **Accessibility**: Screen reader support and semantic markup
|
||||
- **Testing**: Component testing recommended
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Planned Features
|
||||
- **Push Notifications**: Booking reminders
|
||||
- **Payment Integration**: Stripe payment processing
|
||||
- **Calendar Sync**: Export to device calendars
|
||||
- **Loyalty Program**: Points and rewards system
|
||||
- **Multi-language Support**: Internationalization
|
||||
|
||||
### Technical Improvements
|
||||
- **Offline Support**: Cached data and offline mode
|
||||
- **Performance Optimization**: Code splitting and lazy loading
|
||||
- **Advanced Analytics**: User behavior tracking
|
||||
- **API Rate Limiting**: Prevent abuse
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Add tests if applicable
|
||||
5. Submit a pull request
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
|
||||
## Support
|
||||
|
||||
For support and questions:
|
||||
- Create an issue in the repository
|
||||
- Check the documentation
|
||||
- Review the code comments
|
||||
|
||||
---
|
||||
|
||||
**AppBarber** - Premium barber shop management solution 🎯✨
|
||||
163
README_LOCAL.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# AppBarber - Sistema de Gestão de Barbearia Local
|
||||
|
||||
Este guia explica como usar a versão local do AppBarber com base de dados local.
|
||||
|
||||
## 🔧 Configuração
|
||||
|
||||
### 1. Instalação das Dependências
|
||||
```bash
|
||||
npm install
|
||||
# ou
|
||||
yarn install
|
||||
```
|
||||
|
||||
### 2. Base de Dados Local
|
||||
Este projeto usa **AsyncStorage** para armazenamento local de dados quando o Supabase não está configurado. A base de dados local é criada automaticamente na primeira execução.
|
||||
|
||||
## 📱 Utilizadores de Demo
|
||||
|
||||
### Administrador
|
||||
- **Email:** `admin@barbearia.pt`
|
||||
- **Senha:** `admin123`
|
||||
- **Função:** Acesso total ao painel administrativo
|
||||
|
||||
### Barbeiro
|
||||
- **Email:** `barbeiro@barbearia.pt`
|
||||
- **Senha:** `barber123`
|
||||
- **Função:** Gerir as suas marcações e horários
|
||||
|
||||
### Cliente
|
||||
- **Email:** `cliente@barbearia.pt`
|
||||
- **Senha:** `cliente123`
|
||||
- **Função:** Marcar serviços e ver histórico
|
||||
|
||||
## 🚀 Como Usar
|
||||
|
||||
### 1. Iniciar a Aplicação
|
||||
```bash
|
||||
npm start
|
||||
# ou
|
||||
expo start
|
||||
```
|
||||
|
||||
### 2. Login como Administrador
|
||||
1. Abra a aplicação
|
||||
2. Use as credenciais do administrador
|
||||
3. Terá acesso ao painel de administração
|
||||
|
||||
### 3. Funcionalidades do Administrador
|
||||
- **Ver estatísticas:** Total de marcações, receita, barbeiros ativos, clientes
|
||||
- **Gerir marcações:** Confirmar, cancelar ou ver detalhes das marcações
|
||||
- **Ver clientes:** Informações detalhadas dos clientes
|
||||
- **Gerir barbeiros:** Ver horários e disponibilidade
|
||||
|
||||
### 4. Funcionalidades do Barbeiro
|
||||
- **Ver as suas marcações:** Horários e serviços agendados
|
||||
- **Gerir disponibilidade:** Definir horários de trabalho
|
||||
- **Ver avaliações:** Feedback dos clientes
|
||||
|
||||
### 5. Funcionalidades do Cliente
|
||||
- **Marcar serviços:** Escolher serviços, barbeiros e horários
|
||||
- **Ver histórico:** Marcações passadas e futuras
|
||||
- **Avaliar serviços:** Deixar feedback
|
||||
|
||||
## 📊 Estrutura de Dados
|
||||
|
||||
### Utilizadores
|
||||
- Admin: Controlo total do sistema
|
||||
- Barbeiro: Gestão das suas marcações
|
||||
- Cliente: Marcação de serviços
|
||||
|
||||
### Serviços
|
||||
- Corte Clássico: €35
|
||||
- Escultura de Barba: €28
|
||||
- Barbear com Toalha Quente: €45
|
||||
- Combo Corte + Barba: €55
|
||||
- Tratamento Real: €95
|
||||
- Corte para Crianças: €22
|
||||
|
||||
### Marcações
|
||||
- Estados: Pendente, Confirmado, Concluído, Cancelado
|
||||
- Integração completa com serviços e barbeiros
|
||||
|
||||
## 💾 Armazenamento Local
|
||||
|
||||
A base de dados local armazena:
|
||||
- ✅ Utilizadores e perfis
|
||||
- ✅ Serviços disponíveis
|
||||
- ✅ Barbeiros e horários
|
||||
- ✅ Marcações e histórico
|
||||
- ✅ Avaliações e feedback
|
||||
- ✅ Promoções ativas
|
||||
|
||||
## 🔄 Sincronização
|
||||
|
||||
Quando o Supabase estiver configurado, a aplicação usará automaticamente a base de dados em nuvem. Caso contrário, continuará funcionando com a base de dados local.
|
||||
|
||||
## 🛠️ Desenvolvimento
|
||||
|
||||
### Adicionar Novos Utilizadores
|
||||
```typescript
|
||||
const newUser = await localDatabase.createUser({
|
||||
name: "Nome do Utilizador",
|
||||
email: "email@exemplo.com",
|
||||
phone: "+351 912 345 678",
|
||||
role: "customer", // ou "barber", "admin"
|
||||
loyalty_points: 0
|
||||
});
|
||||
```
|
||||
|
||||
### Criar Marcação
|
||||
```typescript
|
||||
const booking = await localDatabase.createBooking({
|
||||
customer_id: "customer-id",
|
||||
barber_id: "barber-id",
|
||||
service_id: "service-id",
|
||||
booking_date: "2025-01-15",
|
||||
booking_time: "14:00",
|
||||
status: "pending"
|
||||
});
|
||||
```
|
||||
|
||||
### Ver Estatísticas
|
||||
```typescript
|
||||
const stats = await localDatabase.getStats();
|
||||
// Retorna: totalBookings, totalRevenue, activeBarbers, totalCustomers
|
||||
```
|
||||
|
||||
## 🐛 Resolução de Problemas
|
||||
|
||||
### Problemas Comuns
|
||||
1. **Login não funciona:** Verifique se está a usar as credenciais corretas
|
||||
2. **Dados não aparecem:** Reinicie a aplicação para inicializar a base de dados
|
||||
3. **Marcações não salvam:** Verifique se AsyncStorage está instalado
|
||||
|
||||
### Limpar Base de Dados
|
||||
```typescript
|
||||
await localDatabase.clearAll();
|
||||
```
|
||||
|
||||
## 📱 Screenshots
|
||||
|
||||
- **Login:** Tela de autenticação com credenciais de demo
|
||||
- **Painel Admin:** Estatísticas e gestão de marcações
|
||||
- **Marcar Serviço:** Fluxo completo de marcação
|
||||
- **Perfil:** Informações do utilizador e histórico
|
||||
|
||||
## 🎯 Próximos Passos
|
||||
|
||||
1. **Configurar Supabase:** Para dados em nuvem
|
||||
2. **Adicionar notificações:** Lembretes de marcações
|
||||
3. **Integração com pagamentos:** Processamento online
|
||||
4. **Relatórios avançados:** Análise detalhada do negócio
|
||||
|
||||
## 📞 Suporte
|
||||
|
||||
Para suporte técnico ou dúvidas:
|
||||
- Verifique os logs do console
|
||||
- Confirme as credenciais de login
|
||||
- Reinicie a aplicação se necessário
|
||||
|
||||
---
|
||||
|
||||
**Nota:** Esta versão local é perfeita para demonstração, testes e desenvolvimento. Para produção, configure o Supabase para dados em nuvem.
|
||||
143
SUPABASE_SETUP.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# Configuração do Supabase para AppBarber
|
||||
|
||||
Este guia explica como configurar o Supabase para que todas as marcações sejam guardadas na base de dados em vez de apenas localmente.
|
||||
|
||||
## Passo 1: Criar projeto no Supabase
|
||||
|
||||
1. Aceda a [https://supabase.com](https://supabase.com)
|
||||
2. Crie uma conta ou faça login
|
||||
3. Clique em "New Project"
|
||||
4. Escolha um nome para o projeto (ex: "AppBarber")
|
||||
5. Escolha uma password de base de dados e guarde-a
|
||||
6. Selecione a região mais próxima (ex: "South America East" para Portugal)
|
||||
7. Clique em "Create new project" e aguarde a criação (2-3 minutos)
|
||||
|
||||
## Passo 2: Obter credenciais
|
||||
|
||||
1. No dashboard do Supabase, aceda a "Settings" > "API"
|
||||
2. Copie o "Project URL"
|
||||
3. Copie o "anon public key"
|
||||
|
||||
## Passo 3: Configurar o projeto
|
||||
|
||||
1. No arquivo `.env` na raiz do projeto, substitua:
|
||||
```
|
||||
EXPO_PUBLIC_SUPABASE_URL=your-supabase-project-url
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key
|
||||
```
|
||||
Por:
|
||||
```
|
||||
EXPO_PUBLIC_SUPABASE_URL=https://seu-projeto-id.supabase.co
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=sua-chave-anon-real
|
||||
```
|
||||
|
||||
## Passo 4: Criar tabelas no Supabase
|
||||
|
||||
No SQL Editor do Supabase, execute os seguintes comandos:
|
||||
|
||||
```sql
|
||||
-- Tabela de utilizadores
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
phone TEXT,
|
||||
role TEXT NOT NULL CHECK (role IN ('customer', 'barber', 'admin')),
|
||||
photo TEXT,
|
||||
loyalty_points INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Tabela de serviços
|
||||
CREATE TABLE services (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
duration INTEGER NOT NULL,
|
||||
price DECIMAL(10,2) NOT NULL,
|
||||
image TEXT,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Tabela de barbeiros
|
||||
CREATE TABLE barbers (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||
specialty TEXT,
|
||||
bio TEXT,
|
||||
rating DECIMAL(3,1) DEFAULT 4.5,
|
||||
availability JSONB,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Tabela de marcações
|
||||
CREATE TABLE bookings (
|
||||
id TEXT PRIMARY KEY,
|
||||
customer_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||
barber_id TEXT REFERENCES barbers(id) ON DELETE CASCADE,
|
||||
service_id TEXT REFERENCES services(id) ON DELETE CASCADE,
|
||||
booking_date DATE NOT NULL,
|
||||
booking_time TIME NOT NULL,
|
||||
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'completed', 'cancelled')),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Tabela de reviews
|
||||
CREATE TABLE reviews (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||
barber_id TEXT REFERENCES barbers(id) ON DELETE CASCADE,
|
||||
service_id TEXT REFERENCES services(id) ON DELETE SET NULL,
|
||||
rating INTEGER CHECK (rating >= 1 AND rating <= 5),
|
||||
comment TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Tabela de promoções
|
||||
CREATE TABLE promotions (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
discount_percentage INTEGER,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
start_date DATE,
|
||||
end_date DATE,
|
||||
image TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
## Passo 5: Inserir dados iniciais (opcional)
|
||||
|
||||
```sql
|
||||
-- Inserir serviços
|
||||
INSERT INTO services (id, name, description, duration, price, image) VALUES
|
||||
('svc-1', 'Corte Clássico', 'Corte de precisão adaptado ao formato do seu rosto. Inclui lavagem, corte e penteado com produtos premium.', 30, 35.00, 'https://images.unsplash.com/photo-1599351431202-1e0f0137899a?w=400'),
|
||||
('svc-2', 'Escultura de Barba', 'Aparar e moldar a barba completa com tratamento de toalha quente e acabamento com navalha.', 25, 28.00, 'https://images.unsplash.com/photo-1621605815971-fbc98d665033?w=400'),
|
||||
('svc-3', 'Barbear com Toalha Quente', 'Barbear tradicional com navalha, toalhas quentes, óleo pré-barba e bálsamo pós-barba.', 45, 45.00, 'https://images.unsplash.com/photo-1503951914875-452162b0f77f?w=400'),
|
||||
('svc-4', 'Combo Corte + Barba', 'Pacote completo de grooming. Corte de cabelo completo com lavagem e penteado + aparar e moldar barba.', 55, 55.00, 'https://images.unsplash.com/photo-1633681926022-84c23e8cb2d6?w=400'),
|
||||
('svc-5', 'Tratamento Real', 'Experiência premium: corte de cabelo, escultura de barba, barbear com toalha quente, esfoliação facial e massagem no couro cabeludo.', 90, 95.00, 'https://images.unsplash.com/photo-1605497788044-5a32c7078486?w=400'),
|
||||
('svc-6', 'Corte para Crianças', 'Experiência de corte suave e divertida para meninos com menos de 12 anos. Inclui rebuçado!', 20, 22.00, 'https://images.unsplash.com/photo-1622286342621-4bd786c2447c?w=400');
|
||||
```
|
||||
|
||||
## Passo 6: Reiniciar a aplicação
|
||||
|
||||
1. Pare o servidor atual (Ctrl+C)
|
||||
2. Execute novamente: `npm run web`
|
||||
|
||||
## Verificação
|
||||
|
||||
Após configurar, quando fizer uma marcação:
|
||||
- ✅ Os dados serão guardados no Supabase
|
||||
- ✅ Receberá uma notificação detalhada da marcação
|
||||
- ✅ Poderá ver "Ver Minhas Marcações" para aceder ao histórico
|
||||
- ✅ As marcações persistirão mesmo em diferentes dispositivos
|
||||
|
||||
## Suporte
|
||||
|
||||
Se encontrar problemas:
|
||||
1. Verifique que as credenciais no `.env` estão corretas
|
||||
2. Confirme que as tabelas foram criadas no Supabase
|
||||
3. Verifique os RLS (Row Level Security) policies se necessário
|
||||
16
android/.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# OSX
|
||||
#
|
||||
.DS_Store
|
||||
|
||||
# Android/IntelliJ
|
||||
#
|
||||
build/
|
||||
.idea
|
||||
.gradle
|
||||
local.properties
|
||||
*.iml
|
||||
*.hprof
|
||||
.cxx/
|
||||
|
||||
# Bundle artifacts
|
||||
*.jsbundle
|
||||
182
android/app/build.gradle
Normal file
@@ -0,0 +1,182 @@
|
||||
apply plugin: "com.android.application"
|
||||
apply plugin: "org.jetbrains.kotlin.android"
|
||||
apply plugin: "com.facebook.react"
|
||||
|
||||
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
|
||||
|
||||
/**
|
||||
* This is the configuration block to customize your React Native Android app.
|
||||
* By default you don't need to apply any configuration, just uncomment the lines you need.
|
||||
*/
|
||||
react {
|
||||
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
|
||||
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
|
||||
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
|
||||
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
|
||||
|
||||
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
|
||||
// Use Expo CLI to bundle the app, this ensures the Metro config
|
||||
// works correctly with Expo projects.
|
||||
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
|
||||
bundleCommand = "export:embed"
|
||||
|
||||
/* Folders */
|
||||
// The root of your project, i.e. where "package.json" lives. Default is '../..'
|
||||
// root = file("../../")
|
||||
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
|
||||
// reactNativeDir = file("../../node_modules/react-native")
|
||||
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
|
||||
// codegenDir = file("../../node_modules/@react-native/codegen")
|
||||
|
||||
/* Variants */
|
||||
// The list of variants to that are debuggable. For those we're going to
|
||||
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
|
||||
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
|
||||
// debuggableVariants = ["liteDebug", "prodDebug"]
|
||||
|
||||
/* Bundling */
|
||||
// A list containing the node command and its flags. Default is just 'node'.
|
||||
// nodeExecutableAndArgs = ["node"]
|
||||
|
||||
//
|
||||
// The path to the CLI configuration file. Default is empty.
|
||||
// bundleConfig = file(../rn-cli.config.js)
|
||||
//
|
||||
// The name of the generated asset file containing your JS bundle
|
||||
// bundleAssetName = "MyApplication.android.bundle"
|
||||
//
|
||||
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
|
||||
// entryFile = file("../js/MyApplication.android.js")
|
||||
//
|
||||
// A list of extra flags to pass to the 'bundle' commands.
|
||||
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
|
||||
// extraPackagerArgs = []
|
||||
|
||||
/* Hermes Commands */
|
||||
// The hermes compiler command to run. By default it is 'hermesc'
|
||||
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
|
||||
//
|
||||
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
|
||||
// hermesFlags = ["-O", "-output-source-map"]
|
||||
|
||||
/* Autolinking */
|
||||
autolinkLibrariesWithApp()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization).
|
||||
*/
|
||||
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
|
||||
|
||||
/**
|
||||
* The preferred build flavor of JavaScriptCore (JSC)
|
||||
*
|
||||
* For example, to use the international variant, you can use:
|
||||
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
|
||||
*
|
||||
* The international variant includes ICU i18n library and necessary data
|
||||
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
|
||||
* give correct results when using with locales other than en-US. Note that
|
||||
* this variant is about 6MiB larger per architecture than default.
|
||||
*/
|
||||
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
|
||||
|
||||
android {
|
||||
ndkVersion rootProject.ext.ndkVersion
|
||||
|
||||
buildToolsVersion rootProject.ext.buildToolsVersion
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
|
||||
namespace "com.appbarber"
|
||||
defaultConfig {
|
||||
applicationId "com.appbarber"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
}
|
||||
signingConfigs {
|
||||
debug {
|
||||
storeFile file('debug.keystore')
|
||||
storePassword 'android'
|
||||
keyAlias 'androiddebugkey'
|
||||
keyPassword 'android'
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
release {
|
||||
// Caution! In production, you need to generate your own keystore file.
|
||||
// see https://reactnative.dev/docs/signed-apk-android.
|
||||
signingConfig signingConfigs.debug
|
||||
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
|
||||
shrinkResources enableShrinkResources.toBoolean()
|
||||
minifyEnabled enableMinifyInReleaseBuilds
|
||||
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
|
||||
crunchPngs enablePngCrunchInRelease.toBoolean()
|
||||
}
|
||||
}
|
||||
packagingOptions {
|
||||
jniLibs {
|
||||
def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
|
||||
useLegacyPackaging enableLegacyPackaging.toBoolean()
|
||||
}
|
||||
}
|
||||
androidResources {
|
||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
|
||||
// Apply static values from `gradle.properties` to the `android.packagingOptions`
|
||||
// Accepts values in comma delimited lists, example:
|
||||
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
|
||||
["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop ->
|
||||
// Split option: 'foo,bar' -> ['foo', 'bar']
|
||||
def options = (findProperty("android.packagingOptions.$prop") ?: "").split(",");
|
||||
// Trim all elements in place.
|
||||
for (i in 0..<options.size()) options[i] = options[i].trim();
|
||||
// `[] - ""` is essentially `[""].filter(Boolean)` removing all empty strings.
|
||||
options -= ""
|
||||
|
||||
if (options.length > 0) {
|
||||
println "android.packagingOptions.$prop += $options ($options.length)"
|
||||
// Ex: android.packagingOptions.pickFirsts += '**/SCCS/**'
|
||||
options.each {
|
||||
android.packagingOptions[prop] += it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// The version of react-native is set by the React Native Gradle Plugin
|
||||
implementation("com.facebook.react:react-android")
|
||||
|
||||
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
|
||||
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
|
||||
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
|
||||
|
||||
if (isGifEnabled) {
|
||||
// For animated gif support
|
||||
implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}")
|
||||
}
|
||||
|
||||
if (isWebpEnabled) {
|
||||
// For webp support
|
||||
implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}")
|
||||
if (isWebpAnimatedEnabled) {
|
||||
// Animated webp support
|
||||
implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}")
|
||||
}
|
||||
}
|
||||
|
||||
if (hermesEnabled.toBoolean()) {
|
||||
implementation("com.facebook.react:hermes-android")
|
||||
} else {
|
||||
implementation jscFlavor
|
||||
}
|
||||
}
|
||||
BIN
android/app/debug.keystore
Normal file
14
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# By default, the flags in this file are appended to flags specified
|
||||
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
|
||||
# You can edit the include path and order by changing the proguardFiles
|
||||
# directive in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# react-native-reanimated
|
||||
-keep class com.swmansion.reanimated.** { *; }
|
||||
-keep class com.facebook.react.turbomodule.** { *; }
|
||||
|
||||
# Add any project specific keep options here:
|
||||
7
android/app/src/debug/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
|
||||
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
|
||||
</manifest>
|
||||
7
android/app/src/debugOptimized/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
|
||||
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
|
||||
</manifest>
|
||||
29
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,29 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- OPTIONAL PERMISSIONS, REMOVE WHATEVER YOU DO NOT NEED -->
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<!-- These require runtime permissions on M -->
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<!-- END OPTIONAL PERMISSIONS -->
|
||||
|
||||
<queries>
|
||||
<!-- Support checking for http(s) links via the Linking API -->
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" android:supportsRtl="true">
|
||||
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
61
android/app/src/main/java/com/appbarber/MainActivity.kt
Normal file
@@ -0,0 +1,61 @@
|
||||
package com.appbarber
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
|
||||
import com.facebook.react.ReactActivity
|
||||
import com.facebook.react.ReactActivityDelegate
|
||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
||||
import com.facebook.react.defaults.DefaultReactActivityDelegate
|
||||
|
||||
import expo.modules.ReactActivityDelegateWrapper
|
||||
|
||||
class MainActivity : ReactActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Set the theme to AppTheme BEFORE onCreate to support
|
||||
// coloring the background, status bar, and navigation bar.
|
||||
// This is required for expo-splash-screen.
|
||||
setTheme(R.style.AppTheme);
|
||||
super.onCreate(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name of the main component registered from JavaScript. This is used to schedule
|
||||
* rendering of the component.
|
||||
*/
|
||||
override fun getMainComponentName(): String = "main"
|
||||
|
||||
/**
|
||||
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
|
||||
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
|
||||
*/
|
||||
override fun createReactActivityDelegate(): ReactActivityDelegate {
|
||||
return ReactActivityDelegateWrapper(
|
||||
this,
|
||||
BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
|
||||
object : DefaultReactActivityDelegate(
|
||||
this,
|
||||
mainComponentName,
|
||||
fabricEnabled
|
||||
){})
|
||||
}
|
||||
|
||||
/**
|
||||
* Align the back button behavior with Android S
|
||||
* where moving root activities to background instead of finishing activities.
|
||||
* @see <a href="https://developer.android.com/reference/android/app/Activity#onBackPressed()">onBackPressed</a>
|
||||
*/
|
||||
override fun invokeDefaultOnBackPressed() {
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
|
||||
if (!moveTaskToBack(false)) {
|
||||
// For non-root activities, use the default implementation to finish them.
|
||||
super.invokeDefaultOnBackPressed()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Use the default back button implementation on Android S
|
||||
// because it's doing more than [Activity.moveTaskToBack] in fact.
|
||||
super.invokeDefaultOnBackPressed()
|
||||
}
|
||||
}
|
||||
56
android/app/src/main/java/com/appbarber/MainApplication.kt
Normal file
@@ -0,0 +1,56 @@
|
||||
package com.appbarber
|
||||
|
||||
import android.app.Application
|
||||
import android.content.res.Configuration
|
||||
|
||||
import com.facebook.react.PackageList
|
||||
import com.facebook.react.ReactApplication
|
||||
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
|
||||
import com.facebook.react.ReactNativeHost
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.ReactHost
|
||||
import com.facebook.react.common.ReleaseLevel
|
||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
|
||||
import com.facebook.react.defaults.DefaultReactNativeHost
|
||||
|
||||
import expo.modules.ApplicationLifecycleDispatcher
|
||||
import expo.modules.ReactNativeHostWrapper
|
||||
|
||||
class MainApplication : Application(), ReactApplication {
|
||||
|
||||
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
|
||||
this,
|
||||
object : DefaultReactNativeHost(this) {
|
||||
override fun getPackages(): List<ReactPackage> =
|
||||
PackageList(this).packages.apply {
|
||||
// Packages that cannot be autolinked yet can be added manually here, for example:
|
||||
// add(MyReactNativePackage())
|
||||
}
|
||||
|
||||
override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
|
||||
|
||||
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
|
||||
|
||||
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
|
||||
}
|
||||
)
|
||||
|
||||
override val reactHost: ReactHost
|
||||
get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
DefaultNewArchitectureEntryPoint.releaseLevel = try {
|
||||
ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
|
||||
} catch (e: IllegalArgumentException) {
|
||||
ReleaseLevel.STABLE
|
||||
}
|
||||
loadReactNative(this)
|
||||
ApplicationLifecycleDispatcher.onApplicationCreate(this)
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
|
||||
}
|
||||
}
|
||||
BIN
android/app/src/main/res/drawable-hdpi/splashscreen_logo.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
android/app/src/main/res/drawable-mdpi/splashscreen_logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
@@ -0,0 +1,6 @@
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/splashscreen_background"/>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splashscreen_logo"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
37
android/app/src/main/res/drawable/rn_edit_text_material.xml
Normal file
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Copyright (C) 2014 The Android Open Source Project
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
|
||||
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
|
||||
android:insetTop="@dimen/abc_edit_text_inset_top_material"
|
||||
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"
|
||||
>
|
||||
|
||||
<selector>
|
||||
<!--
|
||||
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
|
||||
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
|
||||
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
|
||||
|
||||
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
|
||||
|
||||
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
|
||||
-->
|
||||
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
|
||||
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
|
||||
</selector>
|
||||
|
||||
</inset>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
4
android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<resources>
|
||||
<color name="splashscreen_background">#FFFFFF</color>
|
||||
</resources>
|
||||
3
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">AppBarber</string>
|
||||
</resources>
|
||||
8
android/app/src/main/res/values/styles.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<resources>
|
||||
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
||||
</style>
|
||||
<style name="Theme.App.SplashScreen" parent="AppTheme">
|
||||
<item name="android:windowBackground">@drawable/splashscreen_logo</item>
|
||||
</style>
|
||||
</resources>
|
||||
24
android/build.gradle
Normal file
@@ -0,0 +1,24 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath('com.android.tools.build:gradle')
|
||||
classpath('com.facebook.react:react-native-gradle-plugin')
|
||||
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url 'https://www.jitpack.io' }
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: "expo-root-project"
|
||||
apply plugin: "com.facebook.react.rootproject"
|
||||
61
android/gradle.properties
Normal file
@@ -0,0 +1,61 @@
|
||||
# Project-wide Gradle settings.
|
||||
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
|
||||
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
org.gradle.parallel=true
|
||||
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
|
||||
# Enable AAPT2 PNG crunching
|
||||
android.enablePngCrunchInReleaseBuilds=true
|
||||
|
||||
# Use this property to specify which architecture you want to build.
|
||||
# You can also override it from the CLI using
|
||||
# ./gradlew <task> -PreactNativeArchitectures=x86_64
|
||||
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
|
||||
|
||||
# Use this property to enable support to the new architecture.
|
||||
# This will allow you to use TurboModules and the Fabric render in
|
||||
# your application. You should enable this flag either if you want
|
||||
# to write custom TurboModules/Fabric components OR use libraries that
|
||||
# are providing them.
|
||||
newArchEnabled=true
|
||||
|
||||
# Use this property to enable or disable the Hermes JS engine.
|
||||
# If set to false, you will be using JSC instead.
|
||||
hermesEnabled=true
|
||||
|
||||
# Use this property to enable edge-to-edge display support.
|
||||
# This allows your app to draw behind system bars for an immersive UI.
|
||||
# Note: Only works with ReactActivity and should not be used with custom Activity.
|
||||
edgeToEdgeEnabled=true
|
||||
|
||||
# Enable GIF support in React Native images (~200 B increase)
|
||||
expo.gif.enabled=true
|
||||
# Enable webp support in React Native images (~85 KB increase)
|
||||
expo.webp.enabled=true
|
||||
# Enable animated webp support (~3.4 MB increase)
|
||||
# Disabled by default because iOS doesn't support animated webp
|
||||
expo.webp.animated=false
|
||||
|
||||
# Enable network inspector
|
||||
EX_DEV_CLIENT_NETWORK_INSPECTOR=true
|
||||
|
||||
# Use legacy packaging to compress native libraries in the resulting APK.
|
||||
expo.useLegacyPackaging=false
|
||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
251
android/gradlew
vendored
Executable file
@@ -0,0 +1,251 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
android/gradlew.bat
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
39
android/settings.gradle
Normal file
@@ -0,0 +1,39 @@
|
||||
pluginManagement {
|
||||
def reactNativeGradlePlugin = new File(
|
||||
providers.exec {
|
||||
workingDir(rootDir)
|
||||
commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
|
||||
}.standardOutput.asText.get().trim()
|
||||
).getParentFile().absolutePath
|
||||
includeBuild(reactNativeGradlePlugin)
|
||||
|
||||
def expoPluginsPath = new File(
|
||||
providers.exec {
|
||||
workingDir(rootDir)
|
||||
commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
|
||||
}.standardOutput.asText.get().trim(),
|
||||
"../android/expo-gradle-plugin"
|
||||
).absolutePath
|
||||
includeBuild(expoPluginsPath)
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("com.facebook.react.settings")
|
||||
id("expo-autolinking-settings")
|
||||
}
|
||||
|
||||
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
|
||||
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
|
||||
ex.autolinkLibrariesFromCommand()
|
||||
} else {
|
||||
ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
|
||||
}
|
||||
}
|
||||
expoAutolinking.useExpoModules()
|
||||
|
||||
rootProject.name = 'AppBarber'
|
||||
|
||||
expoAutolinking.useExpoVersionCatalog()
|
||||
|
||||
include ':app'
|
||||
includeBuild(expoAutolinking.reactNativeGradlePlugin)
|
||||
20
app.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "AppBarber",
|
||||
"slug": "AppBarber",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"userInterfaceStyle": "dark",
|
||||
"platforms": ["ios", "android", "web"],
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#0a0a0a"
|
||||
},
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png",
|
||||
"bundler": "metro"
|
||||
}
|
||||
}
|
||||
}
|
||||
6
babel.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = function(api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
};
|
||||
};
|
||||
150
database/schema.sql
Normal file
@@ -0,0 +1,150 @@
|
||||
-- Create users table
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
phone VARCHAR(20),
|
||||
role VARCHAR(20) NOT NULL CHECK (role IN ('customer', 'barber', 'admin')),
|
||||
photo TEXT,
|
||||
loyalty_points INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create services table
|
||||
CREATE TABLE services (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
duration INTEGER NOT NULL, -- in minutes
|
||||
price DECIMAL(10,2) NOT NULL,
|
||||
image TEXT,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create barbers table
|
||||
CREATE TABLE barbers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
specialty VARCHAR(255) NOT NULL,
|
||||
bio TEXT,
|
||||
rating DECIMAL(3,2) DEFAULT 0.0 CHECK (rating >= 0 AND rating <= 5),
|
||||
availability JSONB DEFAULT '{}', -- JSON object with days and time slots
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
UNIQUE(user_id)
|
||||
);
|
||||
|
||||
-- Create bookings table
|
||||
CREATE TABLE bookings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
customer_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
barber_id UUID REFERENCES barbers(id) ON DELETE CASCADE,
|
||||
service_id UUID REFERENCES services(id) ON DELETE CASCADE,
|
||||
booking_date DATE NOT NULL,
|
||||
booking_time TIME NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'completed', 'cancelled')),
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create reviews table
|
||||
CREATE TABLE reviews (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
barber_id UUID REFERENCES barbers(id) ON DELETE CASCADE,
|
||||
booking_id UUID REFERENCES bookings(id) ON DELETE SET NULL,
|
||||
rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
|
||||
comment TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
UNIQUE(user_id, barber_id, booking_id)
|
||||
);
|
||||
|
||||
-- Create promotions table
|
||||
CREATE TABLE promotions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
discount_percentage INTEGER NOT NULL CHECK (discount_percentage > 0 AND discount_percentage <= 100),
|
||||
service_id UUID REFERENCES services(id) ON DELETE CASCADE,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
image TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create indexes for better performance
|
||||
CREATE INDEX idx_barbers_user_id ON barbers(user_id);
|
||||
CREATE INDEX idx_bookings_customer_id ON bookings(customer_id);
|
||||
CREATE INDEX idx_bookings_barber_id ON bookings(barber_id);
|
||||
CREATE INDEX idx_bookings_service_id ON bookings(service_id);
|
||||
CREATE INDEX idx_bookings_date_time ON bookings(booking_date, booking_time);
|
||||
CREATE INDEX idx_reviews_user_id ON reviews(user_id);
|
||||
CREATE INDEX idx_reviews_barber_id ON reviews(barber_id);
|
||||
CREATE INDEX idx_users_email ON users(email);
|
||||
CREATE INDEX idx_users_role ON users(role);
|
||||
|
||||
-- Create function to update updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Create triggers for updated_at
|
||||
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_services_updated_at BEFORE UPDATE ON services FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_barbers_updated_at BEFORE UPDATE ON barbers FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_bookings_updated_at BEFORE UPDATE ON bookings FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_reviews_updated_at BEFORE UPDATE ON reviews FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_promotions_updated_at BEFORE UPDATE ON promotions FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Insert sample data
|
||||
INSERT INTO services (name, description, duration, price, image) VALUES
|
||||
('Classic Haircut', 'Traditional haircut with scissors and clippers', 30, 25.00, 'https://images.unsplash.com/photo-1560069007-67cba843241f?w=400'),
|
||||
('Beard Trim', 'Professional beard shaping and trimming', 20, 15.00, 'https://images.unsplash.com/photo-1622286342621-4bd786c2447c?w=400'),
|
||||
('Hot Towel Shave', 'Luxurious hot towel straight razor shave', 45, 35.00, 'https://images.unsplash.com/photo-1596468138837-0c38f5fca1b3?w=400'),
|
||||
('Haircut & Beard', 'Complete haircut and beard trim package', 45, 35.00, 'https://images.unsplash.com/photo-1585747860715-1ba5b1b0ba72?w=400'),
|
||||
('Kids Haircut', 'Haircut for children under 12', 25, 20.00, 'https://images.unsplash.com/photo-1503931975084-1a8352b4c66d?w=400');
|
||||
|
||||
-- Enable Row Level Security (RLS)
|
||||
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE barbers ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE services ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE bookings ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE reviews ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE promotions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Create RLS policies
|
||||
-- Users can view all users but only update their own profile
|
||||
CREATE POLICY "Users can view all users" ON users FOR SELECT USING (true);
|
||||
CREATE POLICY "Users can update own profile" ON users FOR UPDATE USING (auth.uid() = id);
|
||||
|
||||
-- Anyone can view active services
|
||||
CREATE POLICY "Services are viewable by everyone" ON services FOR SELECT USING (is_active = true);
|
||||
|
||||
-- Barbers can be viewed by everyone
|
||||
CREATE POLICY "Barbers are viewable by everyone" ON barbers FOR SELECT USING (is_active = true);
|
||||
|
||||
-- Users can view their own bookings and barbers can view bookings assigned to them
|
||||
CREATE POLICY "Users can view own bookings" ON bookings FOR SELECT USING (auth.uid() = customer_id);
|
||||
CREATE POLICY "Barbers can view their bookings" ON bookings FOR SELECT USING (
|
||||
EXISTS (SELECT 1 FROM barbers WHERE barbers.id = barber_id AND barbers.user_id = auth.uid())
|
||||
);
|
||||
CREATE POLICY "Users can create bookings" ON bookings FOR INSERT WITH CHECK (auth.uid() = customer_id);
|
||||
|
||||
-- Reviews can be viewed by everyone
|
||||
CREATE POLICY "Reviews are viewable by everyone" ON reviews FOR SELECT USING (true);
|
||||
CREATE POLICY "Users can create reviews" ON reviews FOR INSERT WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
-- Active promotions can be viewed by everyone
|
||||
CREATE POLICY "Active promotions are viewable by everyone" ON promotions FOR SELECT USING (is_active = true);
|
||||
8
index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { registerRootComponent } from 'expo';
|
||||
|
||||
import App from './App';
|
||||
|
||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||
// the environment is set up appropriately
|
||||
registerRootComponent(App);
|
||||
30
ios/.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# OSX
|
||||
#
|
||||
.DS_Store
|
||||
|
||||
# Xcode
|
||||
#
|
||||
build/
|
||||
*.pbxuser
|
||||
!default.pbxuser
|
||||
*.mode1v3
|
||||
!default.mode1v3
|
||||
*.mode2v3
|
||||
!default.mode2v3
|
||||
*.perspectivev3
|
||||
!default.perspectivev3
|
||||
xcuserdata
|
||||
*.xccheckout
|
||||
*.moved-aside
|
||||
DerivedData
|
||||
*.hmap
|
||||
*.ipa
|
||||
*.xcuserstate
|
||||
project.xcworkspace
|
||||
.xcode.env.local
|
||||
|
||||
# Bundle artifacts
|
||||
*.jsbundle
|
||||
|
||||
# CocoaPods
|
||||
/Pods/
|
||||
11
ios/.xcode.env
Normal file
@@ -0,0 +1,11 @@
|
||||
# This `.xcode.env` file is versioned and is used to source the environment
|
||||
# used when running script phases inside Xcode.
|
||||
# To customize your local environment, you can create an `.xcode.env.local`
|
||||
# file that is not versioned.
|
||||
|
||||
# NODE_BINARY variable contains the PATH to the node executable.
|
||||
#
|
||||
# Customize the NODE_BINARY variable here.
|
||||
# For example, to use nvm with brew, add the following line
|
||||
# . "$(brew --prefix nvm)/nvm.sh" --no-use
|
||||
export NODE_BINARY=$(command -v node)
|
||||
432
ios/AppBarber.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,432 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
||||
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
13B07F961A680F5B00A75B9A /* AppBarber.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AppBarber.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = AppBarber/Images.xcassets; sourceTree = "<group>"; };
|
||||
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = AppBarber/Info.plist; sourceTree = "<group>"; };
|
||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = AppBarber/SplashScreen.storyboard; sourceTree = "<group>"; };
|
||||
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
|
||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
||||
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = AppBarber/AppDelegate.swift; sourceTree = "<group>"; };
|
||||
F11748442D0722820044C1D9 /* AppBarber-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "AppBarber-Bridging-Header.h"; path = "AppBarber/AppBarber-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
13B07F8C1A680F5B00A75B9A /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
13B07FAE1A68108700A75B9A /* AppBarber */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F11748412D0307B40044C1D9 /* AppDelegate.swift */,
|
||||
F11748442D0722820044C1D9 /* AppBarber-Bridging-Header.h */,
|
||||
BB2F792B24A3F905000567C9 /* Supporting */,
|
||||
13B07FB51A68108700A75B9A /* Images.xcassets */,
|
||||
13B07FB61A68108700A75B9A /* Info.plist */,
|
||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
|
||||
);
|
||||
name = AppBarber;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2D16E6871FA4F8E400B85C8A /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
832341AE1AAA6A7D00B99B32 /* Libraries */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
name = Libraries;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
83CBB9F61A601CBA00E9B192 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
13B07FAE1A68108700A75B9A /* AppBarber */,
|
||||
832341AE1AAA6A7D00B99B32 /* Libraries */,
|
||||
83CBBA001A601CBA00E9B192 /* Products */,
|
||||
2D16E6871FA4F8E400B85C8A /* Frameworks */,
|
||||
);
|
||||
indentWidth = 2;
|
||||
sourceTree = "<group>";
|
||||
tabWidth = 2;
|
||||
usesTabs = 0;
|
||||
};
|
||||
83CBBA001A601CBA00E9B192 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
13B07F961A680F5B00A75B9A /* AppBarber.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BB2F792B24A3F905000567C9 /* Supporting */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BB2F792C24A3F905000567C9 /* Expo.plist */,
|
||||
);
|
||||
name = Supporting;
|
||||
path = AppBarber/Supporting;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
13B07F861A680F5B00A75B9A /* AppBarber */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "AppBarber" */;
|
||||
buildPhases = (
|
||||
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
|
||||
13B07F871A680F5B00A75B9A /* Sources */,
|
||||
13B07F8C1A680F5B00A75B9A /* Frameworks */,
|
||||
13B07F8E1A680F5B00A75B9A /* Resources */,
|
||||
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
|
||||
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = AppBarber;
|
||||
productName = AppBarber;
|
||||
productReference = 13B07F961A680F5B00A75B9A /* AppBarber.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
83CBB9F71A601CBA00E9B192 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastUpgradeCheck = 1130;
|
||||
TargetAttributes = {
|
||||
13B07F861A680F5B00A75B9A = {
|
||||
LastSwiftMigration = 1250;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "AppBarber" */;
|
||||
compatibilityVersion = "Xcode 3.2";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 83CBB9F61A601CBA00E9B192;
|
||||
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
13B07F861A680F5B00A75B9A /* AppBarber */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
13B07F8E1A680F5B00A75B9A /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
|
||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"$(SRCROOT)/.xcode.env",
|
||||
"$(SRCROOT)/.xcode.env.local",
|
||||
);
|
||||
name = "Bundle React Native code and images";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n";
|
||||
};
|
||||
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-AppBarber-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-AppBarber/Pods-AppBarber-resources.sh",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXUpdates/EXUpdates.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/RCTI18nStrings.bundle",
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXUpdates.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCTI18nStrings.bundle",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-AppBarber/Pods-AppBarber-resources.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
13B07F871A680F5B00A75B9A /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
13B07F941A680F5B00A75B9A /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
ENABLE_BITCODE = NO;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"$(inherited)",
|
||||
"FB_SONARKIT_ENABLED=1",
|
||||
);
|
||||
INFOPLIST_FILE = AppBarber/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
"-lc++",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.name.AppBarber;
|
||||
PRODUCT_NAME = AppBarber;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "AppBarber/AppBarber-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
13B07F951A680F5B00A75B9A /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
INFOPLIST_FILE = AppBarber/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
"-lc++",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.name.AppBarber;
|
||||
PRODUCT_NAME = AppBarber;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "AppBarber/AppBarber-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
83CBBA201A601CBA00E9B192 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_SYMBOLS_PRIVATE_EXTERN = NO;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
/usr/lib/swift,
|
||||
"$(inherited)",
|
||||
);
|
||||
LIBRARY_SEARCH_PATHS = "\"$(inherited)\"";
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
83CBBA211A601CBA00E9B192 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = YES;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
/usr/lib/swift,
|
||||
"$(inherited)",
|
||||
);
|
||||
LIBRARY_SEARCH_PATHS = "\"$(inherited)\"";
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "AppBarber" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
13B07F941A680F5B00A75B9A /* Debug */,
|
||||
13B07F951A680F5B00A75B9A /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "AppBarber" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
83CBBA201A601CBA00E9B192 /* Debug */,
|
||||
83CBBA211A601CBA00E9B192 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1130"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||
BuildableName = "AppBarber.app"
|
||||
BlueprintName = "AppBarber"
|
||||
ReferencedContainer = "container:AppBarber.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "00E356ED1AD99517003FC87E"
|
||||
BuildableName = "AppBarberTests.xctest"
|
||||
BlueprintName = "AppBarberTests"
|
||||
ReferencedContainer = "container:AppBarber.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||
BuildableName = "AppBarber.app"
|
||||
BlueprintName = "AppBarber"
|
||||
ReferencedContainer = "container:AppBarber.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||
BuildableName = "AppBarber.app"
|
||||
BlueprintName = "AppBarber"
|
||||
ReferencedContainer = "container:AppBarber.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
BIN
ios/AppBarber.zip
Normal file
3
ios/AppBarber/AppBarber-Bridging-Header.h
Normal file
@@ -0,0 +1,3 @@
|
||||
//
|
||||
// Use this file to import your target's public headers that you would like to expose to Swift.
|
||||
//
|
||||
70
ios/AppBarber/AppDelegate.swift
Normal file
@@ -0,0 +1,70 @@
|
||||
import Expo
|
||||
import React
|
||||
import ReactAppDependencyProvider
|
||||
|
||||
@UIApplicationMain
|
||||
public class AppDelegate: ExpoAppDelegate {
|
||||
var window: UIWindow?
|
||||
|
||||
var reactNativeDelegate: ExpoReactNativeFactoryDelegate?
|
||||
var reactNativeFactory: RCTReactNativeFactory?
|
||||
|
||||
public override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool {
|
||||
let delegate = ReactNativeDelegate()
|
||||
let factory = ExpoReactNativeFactory(delegate: delegate)
|
||||
delegate.dependencyProvider = RCTAppDependencyProvider()
|
||||
|
||||
reactNativeDelegate = delegate
|
||||
reactNativeFactory = factory
|
||||
bindReactNativeFactory(factory)
|
||||
|
||||
#if os(iOS) || os(tvOS)
|
||||
window = UIWindow(frame: UIScreen.main.bounds)
|
||||
factory.startReactNative(
|
||||
withModuleName: "main",
|
||||
in: window,
|
||||
launchOptions: launchOptions)
|
||||
#endif
|
||||
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
// Linking API
|
||||
public override func application(
|
||||
_ app: UIApplication,
|
||||
open url: URL,
|
||||
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
|
||||
) -> Bool {
|
||||
return super.application(app, open: url, options: options) || RCTLinkingManager.application(app, open: url, options: options)
|
||||
}
|
||||
|
||||
// Universal Links
|
||||
public override func application(
|
||||
_ application: UIApplication,
|
||||
continue userActivity: NSUserActivity,
|
||||
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
|
||||
) -> Bool {
|
||||
let result = RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
||||
return super.application(application, continue: userActivity, restorationHandler: restorationHandler) || result
|
||||
}
|
||||
}
|
||||
|
||||
class ReactNativeDelegate: ExpoReactNativeFactoryDelegate {
|
||||
// Extension point for config-plugins
|
||||
|
||||
override func sourceURL(for bridge: RCTBridge) -> URL? {
|
||||
// needed to return the correct URL for expo-dev-client.
|
||||
bridge.bundleURL ?? bundleURL()
|
||||
}
|
||||
|
||||
override func bundleURL() -> URL? {
|
||||
#if DEBUG
|
||||
return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry")
|
||||
#else
|
||||
return Bundle.main.url(forResource: "main", withExtension: "jsbundle")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "expo"
|
||||
}
|
||||
}
|
||||
6
ios/AppBarber/Images.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "expo"
|
||||
}
|
||||
}
|
||||
21
ios/AppBarber/Images.xcassets/SplashScreenLegacy.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "SplashScreenLegacy.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
ios/AppBarber/Images.xcassets/SplashScreenLegacy.imageset/SplashScreenLegacy.png
vendored
Normal file
|
After Width: | Height: | Size: 78 KiB |
53
ios/AppBarber/Info.plist
Normal file
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<false/>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>SplashScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>UIStatusBarStyle</key>
|
||||
<string>UIStatusBarStyleDefault</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
47
ios/AppBarber/SplashScreen.storyboard
Normal file
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="24093.7" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="EXPO-VIEWCONTROLLER-1">
|
||||
<device id="retina6_12" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="24053.1"/>
|
||||
<capability name="Named colors" minToolsVersion="9.0"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EXPO-SCENE-1">
|
||||
<objects>
|
||||
<viewController storyboardIdentifier="SplashScreenViewController" id="EXPO-VIEWCONTROLLER-1" sceneMemberID="viewController">
|
||||
<view key="view" userInteractionEnabled="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="EXPO-ContainerView" userLabel="ContainerView">
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="SplashScreen" translatesAutoresizingMaskIntoConstraints="NO" id="EXPO-SplashScreen" userLabel="SplashScreen">
|
||||
<rect key="frame" x="146.66666666666666" y="381" width="100" height="90.333333333333314"/>
|
||||
<accessibility key="accessibilityConfiguration">
|
||||
<accessibilityTraits key="traits" image="YES" notEnabled="YES"/>
|
||||
</accessibility>
|
||||
</imageView>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="Rmq-lb-GrQ"/>
|
||||
<constraints>
|
||||
<constraint firstItem="EXPO-SplashScreen" firstAttribute="centerY" secondItem="EXPO-ContainerView" secondAttribute="centerY" id="0VC-Wk-OaO"/>
|
||||
<constraint firstItem="EXPO-SplashScreen" firstAttribute="centerX" secondItem="EXPO-ContainerView" secondAttribute="centerX" id="zR4-NK-mVN"/>
|
||||
</constraints>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="EXPO-PLACEHOLDER-1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="0.0" y="0.0"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="SplashScreenLogo" width="100" height="90.333335876464844"/>
|
||||
<systemColor name="systemBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
||||
6
ios/AppBarber/Supporting/Expo.plist
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
</dict>
|
||||
</plist>
|
||||
63
ios/Podfile
Normal file
@@ -0,0 +1,63 @@
|
||||
require File.join(File.dirname(`node --print "require.resolve('expo/package.json')"`), "scripts/autolinking")
|
||||
require File.join(File.dirname(`node --print "require.resolve('react-native/package.json')"`), "scripts/react_native_pods")
|
||||
|
||||
require 'json'
|
||||
podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {}
|
||||
|
||||
def ccache_enabled?(podfile_properties)
|
||||
# Environment variable takes precedence
|
||||
return ENV['USE_CCACHE'] == '1' if ENV['USE_CCACHE']
|
||||
|
||||
# Fall back to Podfile properties
|
||||
podfile_properties['apple.ccacheEnabled'] == 'true'
|
||||
end
|
||||
|
||||
ENV['RCT_NEW_ARCH_ENABLED'] ||= '0' if podfile_properties['newArchEnabled'] == 'false'
|
||||
ENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] ||= podfile_properties['EX_DEV_CLIENT_NETWORK_INSPECTOR']
|
||||
ENV['RCT_USE_RN_DEP'] ||= '1' if podfile_properties['ios.buildReactNativeFromSource'] != 'true' && podfile_properties['newArchEnabled'] != 'false'
|
||||
ENV['RCT_USE_PREBUILT_RNCORE'] ||= '1' if podfile_properties['ios.buildReactNativeFromSource'] != 'true' && podfile_properties['newArchEnabled'] != 'false'
|
||||
platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1'
|
||||
|
||||
prepare_react_native_project!
|
||||
|
||||
target 'AppBarber' do
|
||||
use_expo_modules!
|
||||
|
||||
if ENV['EXPO_USE_COMMUNITY_AUTOLINKING'] == '1'
|
||||
config_command = ['node', '-e', "process.argv=['', '', 'config'];require('@react-native-community/cli').run()"];
|
||||
else
|
||||
config_command = [
|
||||
'node',
|
||||
'--no-warnings',
|
||||
'--eval',
|
||||
'require(\'expo/bin/autolinking\')',
|
||||
'expo-modules-autolinking',
|
||||
'react-native-config',
|
||||
'--json',
|
||||
'--platform',
|
||||
'ios'
|
||||
]
|
||||
end
|
||||
|
||||
config = use_native_modules!(config_command)
|
||||
|
||||
use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks']
|
||||
use_frameworks! :linkage => ENV['USE_FRAMEWORKS'].to_sym if ENV['USE_FRAMEWORKS']
|
||||
|
||||
use_react_native!(
|
||||
:path => config[:reactNativePath],
|
||||
:hermes_enabled => podfile_properties['expo.jsEngine'] == nil || podfile_properties['expo.jsEngine'] == 'hermes',
|
||||
# An absolute path to your application root.
|
||||
:app_path => "#{Pod::Config.instance.installation_root}/..",
|
||||
:privacy_file_aggregation_enabled => podfile_properties['apple.privacyManifestAggregationEnabled'] != 'false',
|
||||
)
|
||||
|
||||
post_install do |installer|
|
||||
react_native_post_install(
|
||||
installer,
|
||||
config[:reactNativePath],
|
||||
:mac_catalyst_enabled => false,
|
||||
:ccache_enabled => ccache_enabled?(podfile_properties),
|
||||
)
|
||||
end
|
||||
end
|
||||
4
ios/Podfile.properties.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"expo.jsEngine": "hermes",
|
||||
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true"
|
||||
}
|
||||
7
metro.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
|
||||
/** @type {import('expo/metro-config').MetroConfig} */
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
module.exports = config;
|
||||
10168
package-lock.json
generated
Normal file
43
package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "appbarber",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "expo start --dev-client",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.1.1",
|
||||
"@react-native-async-storage/async-storage": "^3.0.2",
|
||||
"@react-navigation/bottom-tabs": "^7.15.12",
|
||||
"@react-navigation/native": "^7.2.3",
|
||||
"@react-navigation/stack": "^7.8.12",
|
||||
"@stripe/stripe-react-native": "^0.65.0",
|
||||
"@supabase/supabase-js": "^2.105.3",
|
||||
"expo": "~54.0.33",
|
||||
"expo-constants": "^55.0.16",
|
||||
"expo-image-picker": "^55.0.20",
|
||||
"expo-linear-gradient": "^55.0.13",
|
||||
"expo-notifications": "^55.0.22",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-calendars": "^1.1314.0",
|
||||
"react-native-date-picker": "^5.0.13",
|
||||
"react-native-paper": "^5.15.1",
|
||||
"react-native-safe-area-context": "^5.7.0",
|
||||
"react-native-screens": "^4.24.0",
|
||||
"react-native-vector-icons": "^10.3.0",
|
||||
"react-native-web": "^0.21.2"
|
||||
},
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/react": "~18.2.45",
|
||||
"@types/react-native": "^0.73.0",
|
||||
"babel-preset-expo": "^55.0.22",
|
||||
"typescript": "~5.9.2"
|
||||
}
|
||||
}
|
||||
141
src/components/BarberCard.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Image, StyleSheet, Pressable, Alert } from 'react-native';
|
||||
import { COLORS, SIZES, FONTS, SHADOWS } from '../constants/theme';
|
||||
import { Barber } from '../types';
|
||||
|
||||
interface BarberCardProps {
|
||||
barber: Barber;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
const BarberCard: React.FC<BarberCardProps> = ({ barber, onPress }) => {
|
||||
const renderStars = (rating: number) => {
|
||||
const stars = [];
|
||||
const fullStars = Math.floor(rating);
|
||||
const hasHalfStar = rating % 1 !== 0;
|
||||
|
||||
for (let i = 0; i < fullStars; i++) {
|
||||
stars.push(
|
||||
<Text key={i} style={styles.star}>★</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasHalfStar) {
|
||||
stars.push(
|
||||
<Text key="half" style={styles.star}>☆</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyStars = 5 - Math.ceil(rating);
|
||||
for (let i = 0; i < emptyStars; i++) {
|
||||
stars.push(
|
||||
<Text key={`empty-${i}`} style={[styles.star, styles.emptyStar]}>☆</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return stars;
|
||||
};
|
||||
|
||||
const handlePress = () => {
|
||||
Alert.alert(
|
||||
`👨💼 ${barber.user?.name}`,
|
||||
`${barber.specialty}\n⭐ ${barber.rating?.toFixed(1)}/5\n\n${barber.bio}`,
|
||||
[
|
||||
{
|
||||
text: 'Fechar',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Ver Perfil',
|
||||
onPress: onPress,
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.container, pressed && styles.containerPressed]}
|
||||
onPress={handlePress}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: barber.user?.photo || 'https://via.placeholder.com/100' }}
|
||||
style={styles.avatar}
|
||||
/>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.name}>{barber.user?.name}</Text>
|
||||
<Text style={styles.specialty}>{barber.specialty}</Text>
|
||||
<Text style={styles.bio} numberOfLines={2}>
|
||||
{barber.bio}
|
||||
</Text>
|
||||
<View style={styles.ratingContainer}>
|
||||
<View style={styles.stars}>
|
||||
{renderStars(barber.rating)}
|
||||
</View>
|
||||
<Text style={styles.rating}>{barber.rating.toFixed(1)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: COLORS.surface,
|
||||
borderRadius: SIZES.radius,
|
||||
padding: SIZES.padding,
|
||||
marginBottom: SIZES.margin,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
...SHADOWS.medium,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
containerPressed: {
|
||||
opacity: 0.9,
|
||||
},
|
||||
avatar: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
marginRight: SIZES.margin,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
name: {
|
||||
...FONTS.h3,
|
||||
color: COLORS.text,
|
||||
marginBottom: SIZES.base / 2,
|
||||
},
|
||||
specialty: {
|
||||
...FONTS.body,
|
||||
color: COLORS.primary,
|
||||
marginBottom: SIZES.base / 2,
|
||||
},
|
||||
bio: {
|
||||
...FONTS.caption,
|
||||
color: COLORS.textSecondary,
|
||||
marginBottom: SIZES.base,
|
||||
},
|
||||
ratingContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
stars: {
|
||||
flexDirection: 'row',
|
||||
marginRight: SIZES.base / 2,
|
||||
},
|
||||
star: {
|
||||
color: COLORS.primary,
|
||||
fontSize: 16,
|
||||
},
|
||||
emptyStar: {
|
||||
color: COLORS.border,
|
||||
},
|
||||
rating: {
|
||||
...FONTS.caption,
|
||||
color: COLORS.textSecondary,
|
||||
},
|
||||
});
|
||||
|
||||
export default BarberCard;
|
||||
153
src/components/BookingCard.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { COLORS, SIZES, FONTS, SHADOWS } from '../constants/theme';
|
||||
import { Booking } from '../types';
|
||||
|
||||
interface BookingCardProps {
|
||||
booking: Booking;
|
||||
onPress?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
const BookingCard: React.FC<BookingCardProps> = ({ booking, onPress, onCancel }) => {
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'confirmed':
|
||||
return COLORS.success;
|
||||
case 'pending':
|
||||
return COLORS.warning;
|
||||
case 'cancelled':
|
||||
return COLORS.error;
|
||||
case 'completed':
|
||||
return COLORS.textSecondary;
|
||||
default:
|
||||
return COLORS.textSecondary;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('pt-PT', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.container, pressed && styles.containerPressed]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.service}>{booking.service?.name}</Text>
|
||||
<View style={[styles.statusBadge, { backgroundColor: getStatusColor(booking.status) }]}>
|
||||
<Text style={styles.statusText}>{booking.status.toUpperCase()}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.details}>
|
||||
<View style={styles.detailRow}>
|
||||
<Text style={styles.label}>Barbeiro:</Text>
|
||||
<Text style={styles.value}>{booking.barber?.user?.name}</Text>
|
||||
</View>
|
||||
<View style={styles.detailRow}>
|
||||
<Text style={styles.label}>Data:</Text>
|
||||
<Text style={styles.value}>{formatDate(booking.booking_date)}</Text>
|
||||
</View>
|
||||
<View style={styles.detailRow}>
|
||||
<Text style={styles.label}>Hora:</Text>
|
||||
<Text style={styles.value}>{booking.booking_time}</Text>
|
||||
</View>
|
||||
<View style={styles.detailRow}>
|
||||
<Text style={styles.label}>Preço:</Text>
|
||||
<Text style={[styles.value, styles.price]}>€{booking.service?.price}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{booking.status === 'pending' && onCancel && (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.cancelButton, pressed && styles.cancelButtonPressed]}
|
||||
onPress={onCancel}
|
||||
>
|
||||
<Text style={styles.cancelButtonText}>Cancelar Marcação</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: COLORS.surface,
|
||||
borderRadius: SIZES.radius,
|
||||
padding: SIZES.padding,
|
||||
marginBottom: SIZES.margin,
|
||||
...SHADOWS.medium,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
containerPressed: {
|
||||
opacity: 0.95,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: SIZES.margin,
|
||||
},
|
||||
service: {
|
||||
...FONTS.h4,
|
||||
color: COLORS.text,
|
||||
flex: 1,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: SIZES.base,
|
||||
paddingVertical: SIZES.base / 2,
|
||||
borderRadius: SIZES.base / 2,
|
||||
},
|
||||
statusText: {
|
||||
...FONTS.caption,
|
||||
color: COLORS.background,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
details: {
|
||||
marginBottom: SIZES.margin,
|
||||
},
|
||||
detailRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: SIZES.base / 2,
|
||||
},
|
||||
label: {
|
||||
...FONTS.body,
|
||||
color: COLORS.textSecondary,
|
||||
flex: 1,
|
||||
},
|
||||
value: {
|
||||
...FONTS.body,
|
||||
color: COLORS.text,
|
||||
flex: 2,
|
||||
textAlign: 'right',
|
||||
},
|
||||
price: {
|
||||
color: COLORS.primary,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: COLORS.error,
|
||||
borderRadius: SIZES.base,
|
||||
paddingVertical: SIZES.base,
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
cancelButtonPressed: {
|
||||
opacity: 0.8,
|
||||
},
|
||||
cancelButtonText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.text,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
});
|
||||
|
||||
export default BookingCard;
|
||||
119
src/components/ReviewCard.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Image, StyleSheet } from 'react-native';
|
||||
import { COLORS, SIZES, FONTS, SHADOWS } from '../constants/theme';
|
||||
import { Review } from '../types';
|
||||
|
||||
interface ReviewCardProps {
|
||||
review: Review;
|
||||
}
|
||||
|
||||
const ReviewCard: React.FC<ReviewCardProps> = ({ review }) => {
|
||||
const renderStars = (rating: number) => {
|
||||
const stars = [];
|
||||
const fullStars = Math.floor(rating);
|
||||
|
||||
for (let i = 0; i < fullStars; i++) {
|
||||
stars.push(
|
||||
<Text key={i} style={styles.star}>★</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyStars = 5 - fullStars;
|
||||
for (let i = 0; i < emptyStars; i++) {
|
||||
stars.push(
|
||||
<Text key={`empty-${i}`} style={[styles.star, styles.emptyStar]}>☆</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return stars;
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('pt-PT', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Image
|
||||
source={{ uri: review.user?.photo || 'https://via.placeholder.com/40' }}
|
||||
style={styles.avatar}
|
||||
/>
|
||||
<View style={styles.userInfo}>
|
||||
<Text style={styles.userName}>{review.user?.name}</Text>
|
||||
<View style={styles.ratingContainer}>
|
||||
<View style={styles.stars}>
|
||||
{renderStars(review.rating)}
|
||||
</View>
|
||||
<Text style={styles.date}>
|
||||
{review.created_at ? formatDate(review.created_at) : ''}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.comment}>{review.comment}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: COLORS.surface,
|
||||
borderRadius: SIZES.radius,
|
||||
padding: SIZES.padding,
|
||||
marginBottom: SIZES.margin,
|
||||
...SHADOWS.light,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: SIZES.margin,
|
||||
},
|
||||
avatar: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
marginRight: SIZES.margin,
|
||||
},
|
||||
userInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
userName: {
|
||||
...FONTS.body,
|
||||
color: COLORS.text,
|
||||
fontWeight: '600',
|
||||
marginBottom: SIZES.base / 2,
|
||||
},
|
||||
ratingContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
stars: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
star: {
|
||||
color: COLORS.primary,
|
||||
fontSize: 14,
|
||||
marginRight: 2,
|
||||
},
|
||||
emptyStar: {
|
||||
color: COLORS.border,
|
||||
},
|
||||
date: {
|
||||
...FONTS.caption,
|
||||
color: COLORS.textSecondary,
|
||||
},
|
||||
comment: {
|
||||
...FONTS.body,
|
||||
color: COLORS.text,
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
|
||||
export default ReviewCard;
|
||||
98
src/components/ServiceCard.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Image, StyleSheet, Pressable, Alert } from 'react-native';
|
||||
import { COLORS, SIZES, FONTS, SHADOWS } from '../constants/theme';
|
||||
import { Service } from '../types';
|
||||
|
||||
interface ServiceCardProps {
|
||||
service: Service;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
const ServiceCard: React.FC<ServiceCardProps> = ({ service, onPress }) => {
|
||||
const handlePress = () => {
|
||||
Alert.alert(
|
||||
`💇 ${service.name}`,
|
||||
`Preço: €${service.price} | Duração: ${service.duration} min\n\n${service.description}`,
|
||||
[
|
||||
{
|
||||
text: 'Fechar',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Ver Detalhes',
|
||||
onPress: onPress,
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.container, pressed && styles.containerPressed]}
|
||||
onPress={handlePress}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: service.image || 'https://via.placeholder.com/150' }}
|
||||
style={styles.image}
|
||||
/>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.name}>{service.name}</Text>
|
||||
<Text style={styles.description} numberOfLines={2}>
|
||||
{service.description}
|
||||
</Text>
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.price}>€{service.price}</Text>
|
||||
<Text style={styles.duration}>{service.duration} min</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: COLORS.surface,
|
||||
borderRadius: SIZES.radius,
|
||||
overflow: 'hidden',
|
||||
marginBottom: SIZES.margin,
|
||||
...SHADOWS.medium,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
containerPressed: {
|
||||
opacity: 0.9,
|
||||
},
|
||||
image: {
|
||||
width: '100%',
|
||||
height: 150,
|
||||
resizeMode: 'cover',
|
||||
},
|
||||
content: {
|
||||
padding: SIZES.padding,
|
||||
},
|
||||
name: {
|
||||
...FONTS.h3,
|
||||
color: COLORS.text,
|
||||
marginBottom: SIZES.base / 2,
|
||||
},
|
||||
description: {
|
||||
...FONTS.body,
|
||||
color: COLORS.textSecondary,
|
||||
marginBottom: SIZES.base,
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
price: {
|
||||
...FONTS.h4,
|
||||
color: COLORS.primary,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
duration: {
|
||||
...FONTS.caption,
|
||||
color: COLORS.textSecondary,
|
||||
},
|
||||
});
|
||||
|
||||
export default ServiceCard;
|
||||
55
src/constants/theme.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export const COLORS = {
|
||||
primary: '#D4AF37', // Gold
|
||||
secondary: '#1a1a1a', // Dark black
|
||||
background: '#0a0a0a', // Deep black
|
||||
surface: '#1f1f1f', // Card background
|
||||
text: '#ffffff', // White text
|
||||
textSecondary: '#b0b0b0', // Gray text
|
||||
accent: '#FFD700', // Bright gold
|
||||
error: '#ff4444',
|
||||
success: '#44ff44',
|
||||
warning: '#ffaa00',
|
||||
border: '#333333',
|
||||
overlay: 'rgba(0, 0, 0, 0.5)',
|
||||
};
|
||||
|
||||
export const SIZES = {
|
||||
base: 8,
|
||||
font: 14,
|
||||
radius: 12,
|
||||
padding: 16,
|
||||
margin: 16,
|
||||
};
|
||||
|
||||
export const FONTS = {
|
||||
h1: { fontSize: 32, fontWeight: 'bold' as const },
|
||||
h2: { fontSize: 24, fontWeight: 'bold' as const },
|
||||
h3: { fontSize: 20, fontWeight: '600' as const },
|
||||
h4: { fontSize: 18, fontWeight: '600' as const },
|
||||
body: { fontSize: 16, fontWeight: 'normal' as const },
|
||||
caption: { fontSize: 12, fontWeight: 'normal' as const },
|
||||
};
|
||||
|
||||
export const SHADOWS = {
|
||||
light: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
medium: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 8,
|
||||
elevation: 6,
|
||||
},
|
||||
heavy: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 16,
|
||||
elevation: 10,
|
||||
},
|
||||
};
|
||||
294
src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { supabase, isSupabaseConfigured } from '../services/supabase';
|
||||
import { localDatabase, defaultAdmin, defaultBarberUser, defaultCustomer } from '../services/localDatabase';
|
||||
import { User, AuthState } from '../types';
|
||||
|
||||
interface AuthContextType extends AuthState {
|
||||
signIn: (email: string, password: string) => Promise<{ error: any }>;
|
||||
signUp: (email: string, password: string, name: string, phone: string, role: 'customer' | 'barber' | 'admin') => Promise<{ error: any }>;
|
||||
signOut: () => Promise<void>;
|
||||
updateProfile: (updates: Partial<User>) => Promise<{ error: any }>;
|
||||
isDemoMode: boolean;
|
||||
isLocalMode: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [state, setState] = useState<AuthState>({
|
||||
user: null,
|
||||
session: null,
|
||||
loading: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
initializeAuth();
|
||||
}, []);
|
||||
|
||||
const initializeAuth = async () => {
|
||||
try {
|
||||
// Initialize local database
|
||||
await localDatabase.initialize();
|
||||
|
||||
if (isSupabaseConfigured) {
|
||||
// Use Supabase if configured
|
||||
const { data: { session } } = await supabase.auth?.getSession() || { data: { session: null } };
|
||||
if (session) {
|
||||
await fetchUserProfile(session.user.id);
|
||||
} else {
|
||||
setState(prev => ({ ...prev, loading: false }));
|
||||
}
|
||||
|
||||
const { data: { subscription } } = supabase.auth?.onAuthStateChange(
|
||||
async (event: any, session: any) => {
|
||||
if (session) {
|
||||
await fetchUserProfile(session.user.id);
|
||||
} else {
|
||||
setState({ user: null, session: null, loading: false });
|
||||
}
|
||||
}
|
||||
) || { data: { subscription: null } };
|
||||
|
||||
return () => subscription?.unsubscribe();
|
||||
} else {
|
||||
// Use local database
|
||||
const currentUser = await localDatabase.getCurrentUser();
|
||||
if (currentUser) {
|
||||
setState({
|
||||
user: currentUser,
|
||||
session: { user: { id: currentUser.id } },
|
||||
loading: false,
|
||||
});
|
||||
} else {
|
||||
setState({ user: null, session: null, loading: false });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao inicializar autenticação:', error);
|
||||
setState({ user: null, session: null, loading: false });
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUserProfile = async (userId: string) => {
|
||||
try {
|
||||
if (isSupabaseConfigured) {
|
||||
const { data, error } = await supabase
|
||||
.from('users')
|
||||
.select('*')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
setState({
|
||||
user: data,
|
||||
session: { user: { id: userId } },
|
||||
loading: false,
|
||||
});
|
||||
} else {
|
||||
const user = await localDatabase.getUserById(userId);
|
||||
setState({
|
||||
user: user,
|
||||
session: { user: { id: userId } },
|
||||
loading: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao obter perfil:', error);
|
||||
setState(prev => ({ ...prev, loading: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const signIn = async (email: string, password: string) => {
|
||||
try {
|
||||
if (isSupabaseConfigured) {
|
||||
const { error } = await supabase.auth?.signInWithPassword({ email, password });
|
||||
return { error };
|
||||
}
|
||||
|
||||
// Local authentication
|
||||
await localDatabase.initialize();
|
||||
|
||||
// Check for admin login
|
||||
if (email.toLowerCase() === defaultAdmin.email.toLowerCase()) {
|
||||
if (password === 'admin123' || password === 'admin') {
|
||||
await localDatabase.setCurrentUser(defaultAdmin);
|
||||
setState({
|
||||
user: defaultAdmin,
|
||||
session: { user: { id: defaultAdmin.id } },
|
||||
loading: false,
|
||||
});
|
||||
return { error: null };
|
||||
}
|
||||
return { error: { message: 'Palavra-passe incorreta. Use: admin123' } };
|
||||
}
|
||||
|
||||
// Check for barber login
|
||||
if (email.toLowerCase() === defaultBarberUser.email.toLowerCase()) {
|
||||
if (password === 'barber123' || password === 'barbeiro') {
|
||||
await localDatabase.setCurrentUser(defaultBarberUser);
|
||||
setState({
|
||||
user: defaultBarberUser,
|
||||
session: { user: { id: defaultBarberUser.id } },
|
||||
loading: false,
|
||||
});
|
||||
return { error: null };
|
||||
}
|
||||
return { error: { message: 'Palavra-passe incorreta. Use: barber123' } };
|
||||
}
|
||||
|
||||
// Check for customer login
|
||||
if (email.toLowerCase() === defaultCustomer.email.toLowerCase()) {
|
||||
if (password === 'cliente123' || password === 'cliente') {
|
||||
await localDatabase.setCurrentUser(defaultCustomer);
|
||||
setState({
|
||||
user: defaultCustomer,
|
||||
session: { user: { id: defaultCustomer.id } },
|
||||
loading: false,
|
||||
});
|
||||
return { error: null };
|
||||
}
|
||||
return { error: { message: 'Palavra-passe incorreta. Use: cliente123' } };
|
||||
}
|
||||
|
||||
// Check for existing user in local database
|
||||
const existingUser = await localDatabase.getUserByEmail(email);
|
||||
if (existingUser) {
|
||||
await localDatabase.setCurrentUser(existingUser);
|
||||
setState({
|
||||
user: existingUser,
|
||||
session: { user: { id: existingUser.id } },
|
||||
loading: false,
|
||||
});
|
||||
return { error: null };
|
||||
}
|
||||
|
||||
return { error: { message: 'Utilizador não encontrado. Credenciais de demo: admin@barbearia.pt / admin123' } };
|
||||
} catch (error: any) {
|
||||
return { error: { message: error.message || 'Erro ao iniciar sessão' } };
|
||||
}
|
||||
};
|
||||
|
||||
const signUp = async (email: string, password: string, name: string, phone: string, role: 'customer' | 'barber' | 'admin') => {
|
||||
try {
|
||||
if (isSupabaseConfigured) {
|
||||
const { data, error } = await supabase.auth?.signUp({ email, password }) || { data: null, error: null };
|
||||
|
||||
if (error) return { error };
|
||||
|
||||
if (data?.user) {
|
||||
const { error: profileError } = await supabase
|
||||
.from('users')
|
||||
.insert([{ id: data.user.id, name, email, phone, role }]);
|
||||
return { error: profileError };
|
||||
}
|
||||
|
||||
return { error: null };
|
||||
}
|
||||
|
||||
// Local registration
|
||||
await localDatabase.initialize();
|
||||
|
||||
// Check if email already exists
|
||||
const existingUser = await localDatabase.getUserByEmail(email);
|
||||
if (existingUser) {
|
||||
return { error: { message: 'Este e-mail já está registado' } };
|
||||
}
|
||||
|
||||
const newUser = await localDatabase.createUser({
|
||||
name,
|
||||
email,
|
||||
phone,
|
||||
role,
|
||||
loyalty_points: 0,
|
||||
id: '', // Will be generated in the database
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await localDatabase.setCurrentUser(newUser);
|
||||
setState({
|
||||
user: newUser,
|
||||
session: { user: { id: newUser.id } },
|
||||
loading: false,
|
||||
});
|
||||
|
||||
return { error: null };
|
||||
} catch (error: any) {
|
||||
return { error: { message: error.message || 'Erro ao criar conta' } };
|
||||
}
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
try {
|
||||
if (isSupabaseConfigured) {
|
||||
await supabase.auth?.signOut();
|
||||
} else {
|
||||
await localDatabase.setCurrentUser(null);
|
||||
}
|
||||
setState({ user: null, session: null, loading: false });
|
||||
} catch (error) {
|
||||
console.error('Erro ao terminar sessão:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateProfile = async (updates: Partial<User>) => {
|
||||
if (!state.user) return { error: { message: 'Nenhum utilizador autenticado' } };
|
||||
|
||||
try {
|
||||
if (isSupabaseConfigured) {
|
||||
const { error } = await supabase
|
||||
.from('users')
|
||||
.update(updates)
|
||||
.eq('id', state.user.id);
|
||||
|
||||
if (!error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
user: prev.user ? { ...prev.user, ...updates } : null,
|
||||
}));
|
||||
}
|
||||
|
||||
return { error };
|
||||
}
|
||||
|
||||
// Local update
|
||||
const updatedUser = await localDatabase.updateUser(state.user.id, updates);
|
||||
if (updatedUser) {
|
||||
await localDatabase.setCurrentUser(updatedUser);
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
user: updatedUser,
|
||||
}));
|
||||
return { error: null };
|
||||
}
|
||||
|
||||
return { error: { message: 'Erro ao actualizar perfil' } };
|
||||
} catch (error: any) {
|
||||
return { error: { message: error.message || 'Erro ao actualizar perfil' } };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
...state,
|
||||
signIn,
|
||||
signUp,
|
||||
signOut,
|
||||
updateProfile,
|
||||
isDemoMode: !isSupabaseConfigured,
|
||||
isLocalMode: !isSupabaseConfigured,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth deve ser usado dentro de um AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
391
src/data/mockData.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import { Service, Barber, Review, Promotion, Booking, User } from '../types';
|
||||
|
||||
export const mockServices: Service[] = [
|
||||
{
|
||||
id: 'svc-1',
|
||||
name: 'Corte Clássico',
|
||||
description: 'Corte de precisão adaptado ao formato do seu rosto. Inclui lavagem, corte e penteado com produtos premium. Perfeito para quem busca um look profissional e elegante.',
|
||||
duration: 30,
|
||||
price: 35,
|
||||
image: 'https://images.unsplash.com/photo-1599351431202-1e0f0137899a?w=400',
|
||||
created_at: '2025-01-01',
|
||||
},
|
||||
{
|
||||
id: 'svc-2',
|
||||
name: 'Escultura de Barba',
|
||||
description: 'Aparar e moldar a barba completa com tratamento de toalha quente e acabamento com navalha. Ideal para manter uma barba bem cuidada e estilizada.',
|
||||
duration: 25,
|
||||
price: 28,
|
||||
image: 'https://images.unsplash.com/photo-1621605815971-fbc98d665033?w=400',
|
||||
created_at: '2025-01-01',
|
||||
},
|
||||
{
|
||||
id: 'svc-3',
|
||||
name: 'Barbear com Toalha Quente',
|
||||
description: 'Barbear tradicional com navalha, toalhas quentes, óleo pré-barba e bálsamo pós-barba. Uma experiência relaxante e luxuosa.',
|
||||
duration: 45,
|
||||
price: 45,
|
||||
image: 'https://images.unsplash.com/photo-1503951914875-452162b0f77f?w=400',
|
||||
created_at: '2025-01-01',
|
||||
},
|
||||
{
|
||||
id: 'svc-4',
|
||||
name: 'Combo Corte + Barba',
|
||||
description: 'Pacote completo de grooming. Corte de cabelo completo com lavagem e penteado + aparar e moldar barba. Economize tempo e dinheiro.',
|
||||
duration: 55,
|
||||
price: 55,
|
||||
image: 'https://images.unsplash.com/photo-1633681926022-84c23e8cb2d6?w=400',
|
||||
created_at: '2025-01-01',
|
||||
},
|
||||
{
|
||||
id: 'svc-5',
|
||||
name: 'Tratamento Real',
|
||||
description: 'Experiência premium: corte de cabelo, escultura de barba, barbear com toalha quente, esfoliação facial e massagem no couro cabeludo. O máximo em cuidado masculino.',
|
||||
duration: 90,
|
||||
price: 95,
|
||||
image: 'https://images.unsplash.com/photo-1605497788044-5a32c7078486?w=400',
|
||||
created_at: '2025-01-01',
|
||||
},
|
||||
{
|
||||
id: 'svc-6',
|
||||
name: 'Corte para Crianças',
|
||||
description: 'Experiência de corte suave e divertida para meninos com menos de 12 anos. Inclui rebuçado! Nossos barbeiros são especialistas em cortes infantis.',
|
||||
duration: 20,
|
||||
price: 22,
|
||||
image: 'https://images.unsplash.com/photo-1622286342621-4bd786c2447c?w=400',
|
||||
created_at: '2025-01-01',
|
||||
},
|
||||
{
|
||||
id: 'svc-7',
|
||||
name: 'Coloração Masculina',
|
||||
description: 'Cobertura de cinzas ou mudança completa de cor com produtos de alta qualidade que não danificam o cabelo. Inclui consulta personalizada.',
|
||||
duration: 60,
|
||||
price: 65,
|
||||
image: 'https://images.unsplash.com/photo-1621600411682-99823f709ea1?w=400',
|
||||
created_at: '2025-01-01',
|
||||
},
|
||||
{
|
||||
id: 'svc-8',
|
||||
name: 'Tratamento Capilar',
|
||||
description: 'Hidratação profunda e reparação do cabelo com máscaras e óleos premium. Ideal para cabelos danificados por química ou exposição solar.',
|
||||
duration: 40,
|
||||
price: 40,
|
||||
image: 'https://images.unsplash.com/photo-1560869713-bf7a4e1fadd7?w=400',
|
||||
created_at: '2025-01-01',
|
||||
},
|
||||
];
|
||||
|
||||
export const mockBarbers: Barber[] = [
|
||||
{
|
||||
id: 'barb-1',
|
||||
user_id: 'usr-1',
|
||||
specialty: 'Cortes Clássicos & Desvanecidos',
|
||||
bio: 'Mestre barbeiro com 15 anos de experiência. Especialista em desvanecimentos, cortes clássicos e escultura de barba. Formado em Londres e Nova Iorque. Premiado como Melhor Barbeiro de 2023.',
|
||||
rating: 4.9,
|
||||
availability: {
|
||||
monday: ['09:00', '10:00', '11:00', '14:00', '15:00', '16:00'],
|
||||
tuesday: ['09:00', '10:00', '11:00', '14:00', '15:00', '16:00', '17:00'],
|
||||
wednesday: ['10:00', '11:00', '12:00', '14:00', '15:00', '16:00'],
|
||||
thursday: ['09:00', '10:00', '11:00', '14:00', '15:00', '16:00', '17:00'],
|
||||
friday: ['09:00', '10:00', '11:00', '12:00', '14:00', '15:00', '16:00', '17:00'],
|
||||
saturday: ['10:00', '11:00', '12:00', '14:00', '15:00', '16:00'],
|
||||
sunday: [],
|
||||
},
|
||||
user: {
|
||||
id: 'usr-1',
|
||||
name: 'Marcus Johnson',
|
||||
email: 'marcus@appbarber.com',
|
||||
phone: '+351 912 345 678',
|
||||
role: 'barber',
|
||||
photo: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=200',
|
||||
},
|
||||
is_active: true,
|
||||
created_at: '2025-01-01',
|
||||
},
|
||||
{
|
||||
id: 'barb-2',
|
||||
user_id: 'usr-2',
|
||||
specialty: 'Especialista de Barba & Barbears',
|
||||
bio: 'Conhecedor de barbas e especialista em navalha. Conhecido pelo alinhamento perfeito de barba e experiência luxuosa de barbear com toalha quente. 12 anos de experiência em barbearias premium.',
|
||||
rating: 4.8,
|
||||
availability: {
|
||||
monday: ['10:00', '11:00', '12:00', '14:00', '15:00', '16:00', '17:00'],
|
||||
tuesday: ['09:00', '10:00', '11:00', '14:00', '15:00', '16:00'],
|
||||
wednesday: ['09:00', '10:00', '11:00', '14:00', '15:00', '16:00', '17:00'],
|
||||
thursday: ['10:00', '11:00', '12:00', '14:00', '15:00', '16:00'],
|
||||
friday: ['09:00', '10:00', '11:00', '14:00', '15:00', '16:00', '17:00'],
|
||||
saturday: ['10:00', '11:00', '12:00', '14:00', '15:00'],
|
||||
sunday: [],
|
||||
},
|
||||
user: {
|
||||
id: 'usr-2',
|
||||
name: 'Carlos Rivera',
|
||||
email: 'carlos@appbarber.com',
|
||||
phone: '+351 923 456 789',
|
||||
role: 'barber',
|
||||
photo: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=200',
|
||||
},
|
||||
is_active: true,
|
||||
created_at: '2025-01-01',
|
||||
},
|
||||
{
|
||||
id: 'barb-3',
|
||||
user_id: 'usr-3',
|
||||
specialty: 'Estilos Modernos & Cor',
|
||||
bio: 'Tendência em cabelo masculino moderno. Especialista em cortes texturizados, topetes e coloração criativa. Famoso no Instagram por vídeos de transformação.',
|
||||
rating: 4.7,
|
||||
availability: {
|
||||
monday: ['12:00', '13:00', '14:00', '15:00', '16:00', '17:00', '18:00'],
|
||||
tuesday: ['12:00', '13:00', '14:00', '15:00', '16:00', '17:00', '18:00'],
|
||||
wednesday: ['11:00', '12:00', '13:00', '14:00', '15:00', '16:00', '17:00'],
|
||||
thursday: ['12:00', '13:00', '14:00', '15:00', '16:00', '17:00', '18:00'],
|
||||
friday: ['11:00', '12:00', '13:00', '14:00', '15:00', '16:00', '17:00', '18:00'],
|
||||
saturday: ['10:00', '11:00', '12:00', '13:00', '14:00', '15:00', '16:00'],
|
||||
sunday: ['11:00', '12:00', '13:00', '14:00', '15:00'],
|
||||
},
|
||||
user: {
|
||||
id: 'usr-3',
|
||||
name: 'Alex Chen',
|
||||
email: 'alex@appbarber.com',
|
||||
phone: '+351 934 567 890',
|
||||
role: 'barber',
|
||||
photo: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=200',
|
||||
},
|
||||
is_active: true,
|
||||
created_at: '2025-01-01',
|
||||
},
|
||||
{
|
||||
id: 'barb-4',
|
||||
user_id: 'usr-4',
|
||||
specialty: 'Cortes Infantis & Família',
|
||||
bio: 'Especialista em cortes para toda a família, especialmente crianças. Tem o dom de manter os pequenos entretidos durante o corte. 8 anos de experiência dedicada ao público familiar.',
|
||||
rating: 4.6,
|
||||
availability: {
|
||||
monday: ['09:00', '10:00', '11:00', '14:00', '15:00'],
|
||||
tuesday: ['09:00', '10:00', '11:00', '14:00', '15:00'],
|
||||
wednesday: ['09:00', '10:00', '11:00', '14:00', '15:00'],
|
||||
thursday: ['09:00', '10:00', '11:00', '14:00', '15:00'],
|
||||
friday: ['09:00', '10:00', '11:00', '14:00', '15:00'],
|
||||
saturday: ['09:00', '10:00', '11:00', '12:00', '14:00', '15:00', '16:00'],
|
||||
sunday: ['10:00', '11:00', '12:00', '13:00'],
|
||||
},
|
||||
user: {
|
||||
id: 'usr-4',
|
||||
name: 'João Silva',
|
||||
email: 'joao@appbarber.com',
|
||||
phone: '+351 945 678 901',
|
||||
role: 'barber',
|
||||
photo: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=200',
|
||||
},
|
||||
is_active: true,
|
||||
created_at: '2025-01-01',
|
||||
},
|
||||
{
|
||||
id: 'barb-5',
|
||||
user_id: 'usr-5',
|
||||
specialty: 'Tratamentos Capilares & Spa',
|
||||
bio: 'Especialista em tratamentos capilares avançados, hidratação e spa capilar. Certificado em tricologia e produtos orgânicos. Foca na saúde e vitalidade do cabelo.',
|
||||
rating: 4.8,
|
||||
availability: {
|
||||
monday: ['10:00', '11:00', '14:00', '15:00', '16:00'],
|
||||
tuesday: ['10:00', '11:00', '14:00', '15:00', '16:00'],
|
||||
wednesday: ['10:00', '11:00', '14:00', '15:00', '16:00'],
|
||||
thursday: ['10:00', '11:00', '14:00', '15:00', '16:00'],
|
||||
friday: ['10:00', '11:00', '14:00', '15:00', '16:00'],
|
||||
saturday: ['11:00', '12:00', '14:00', '15:00'],
|
||||
sunday: [],
|
||||
},
|
||||
user: {
|
||||
id: 'usr-5',
|
||||
name: 'Pedro Santos',
|
||||
email: 'pedro@appbarber.com',
|
||||
phone: '+351 956 789 012',
|
||||
role: 'barber',
|
||||
photo: 'https://images.unsplash.com/photo-1599566150163-29194dcabd7d?w=200',
|
||||
},
|
||||
is_active: true,
|
||||
created_at: '2025-01-01',
|
||||
},
|
||||
];
|
||||
|
||||
export const mockReviews: Review[] = [
|
||||
{
|
||||
id: 'rev-1',
|
||||
user_id: 'u-1',
|
||||
barber_id: 'barb-1',
|
||||
service_id: 'svc-1',
|
||||
rating: 5,
|
||||
comment: 'O melhor corte de cabelo que já tive! O Marcus realmente entende o que queremos e supera as expectativas. O ambiente da loja é incrível também.',
|
||||
user: {
|
||||
id: 'u-1',
|
||||
name: 'James Wilson',
|
||||
email: 'james@example.com',
|
||||
phone: '+351 912 345 679',
|
||||
role: 'customer',
|
||||
photo: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=100',
|
||||
},
|
||||
created_at: '2025-04-15',
|
||||
},
|
||||
{
|
||||
id: 'rev-2',
|
||||
user_id: 'u-2',
|
||||
barber_id: 'barb-2',
|
||||
service_id: 'svc-3',
|
||||
rating: 5,
|
||||
comment: 'O barbear com toalha quente foi luxo puro. O Carlos é um verdadeiro artista com a navalha. A minha barba nunca esteve melhor!',
|
||||
user: {
|
||||
id: 'u-2',
|
||||
name: 'Robert Martinez',
|
||||
email: 'robert@example.com',
|
||||
phone: '+351 923 456 790',
|
||||
role: 'customer',
|
||||
photo: 'https://images.unsplash.com/photo-1599566150163-29194dcabd7d?w=100',
|
||||
},
|
||||
created_at: '2025-04-10',
|
||||
},
|
||||
{
|
||||
id: 'rev-3',
|
||||
user_id: 'u-3',
|
||||
barber_id: 'barb-1',
|
||||
service_id: 'svc-4',
|
||||
rating: 4,
|
||||
comment: 'Óptimo desvanecimento e atenção aos detalhes. A loja é limpa, a música é boa, os funcionários são simpáticos. Vou voltar com certeza.',
|
||||
user: {
|
||||
id: 'u-3',
|
||||
name: 'David Kim',
|
||||
email: 'david@example.com',
|
||||
phone: '+351 934 567 891',
|
||||
role: 'customer',
|
||||
},
|
||||
created_at: '2025-04-05',
|
||||
},
|
||||
{
|
||||
id: 'rev-4',
|
||||
user_id: 'u-4',
|
||||
barber_id: 'barb-3',
|
||||
service_id: 'svc-7',
|
||||
rating: 5,
|
||||
comment: 'O Alex fez-me o corte mais criativo que já tive. O trabalho de cor é incrível. Todos perguntam onde fiz o cabelo!',
|
||||
user: {
|
||||
id: 'u-4',
|
||||
name: 'Tyler Brooks',
|
||||
email: 'tyler@example.com',
|
||||
phone: '+351 945 678 892',
|
||||
role: 'customer',
|
||||
photo: 'https://images.unsplash.com/photo-1527980965255-d3b416303d12?w=100',
|
||||
},
|
||||
created_at: '2025-03-28',
|
||||
},
|
||||
{
|
||||
id: 'rev-5',
|
||||
user_id: 'u-5',
|
||||
barber_id: 'barb-2',
|
||||
service_id: 'svc-5',
|
||||
rating: 5,
|
||||
comment: 'O pacote Tratamento Real vale cada cêntimo. Só a massagem no couro cabeludo já foi incrível. Verdadeiramente uma experiência premium.',
|
||||
user: {
|
||||
id: 'u-5',
|
||||
name: 'Michael Torres',
|
||||
email: 'michael@example.com',
|
||||
phone: '+351 956 789 893',
|
||||
role: 'customer',
|
||||
},
|
||||
created_at: '2025-03-20',
|
||||
},
|
||||
{
|
||||
id: 'rev-6',
|
||||
user_id: 'u-6',
|
||||
barber_id: 'barb-4',
|
||||
service_id: 'svc-6',
|
||||
rating: 5,
|
||||
comment: 'O João é fantástico com crianças! O meu filho adora ir ao barbeiro agora. Muito paciente e profissional.',
|
||||
user: {
|
||||
id: 'u-6',
|
||||
name: 'Ana Costa',
|
||||
email: 'ana@example.com',
|
||||
phone: '+351 967 890 894',
|
||||
role: 'customer',
|
||||
photo: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=100',
|
||||
},
|
||||
created_at: '2025-03-15',
|
||||
},
|
||||
{
|
||||
id: 'rev-7',
|
||||
user_id: 'u-7',
|
||||
barber_id: 'barb-5',
|
||||
service_id: 'svc-8',
|
||||
rating: 5,
|
||||
comment: 'O tratamento capilar com o Pedro transformou o meu cabelo. Finalmente tenho cabelo saudável e brilhante!',
|
||||
user: {
|
||||
id: 'u-7',
|
||||
name: 'Luis Fernandes',
|
||||
email: 'luis@example.com',
|
||||
phone: '+351 978 901 895',
|
||||
role: 'customer',
|
||||
},
|
||||
created_at: '2025-03-10',
|
||||
},
|
||||
];
|
||||
|
||||
export const mockPromotions: Promotion[] = [
|
||||
{
|
||||
id: 'promo-1',
|
||||
title: 'Especial Primeira Visita',
|
||||
description: 'Novos clientes têm 25% de desconto no primeiro corte. Experimente a diferença premium.',
|
||||
discount_percentage: 25,
|
||||
is_active: true,
|
||||
start_date: '2025-01-01',
|
||||
end_date: '2025-12-31',
|
||||
image: 'https://images.unsplash.com/photo-1585747860715-1ba5b1b0ba72?w=400',
|
||||
created_at: '2025-01-01',
|
||||
},
|
||||
{
|
||||
id: 'promo-2',
|
||||
title: 'Oferta Combo',
|
||||
description: 'Marque Corte + Barba juntos e poupe €15. O pacote completo de grooming.',
|
||||
discount_percentage: 20,
|
||||
is_active: true,
|
||||
start_date: '2025-01-01',
|
||||
end_date: '2025-12-31',
|
||||
image: 'https://images.unsplash.com/photo-1599351431202-1e0f0137899a?w=400',
|
||||
created_at: '2025-01-01',
|
||||
},
|
||||
{
|
||||
id: 'promo-3',
|
||||
title: 'Especial de Fim de Semana',
|
||||
description: 'Marcações de sábado têm 15% de desconto em qualquer serviço. Lugares limitados!',
|
||||
discount_percentage: 15,
|
||||
is_active: true,
|
||||
start_date: '2025-01-01',
|
||||
end_date: '2025-12-31',
|
||||
image: 'https://images.unsplash.com/photo-1605497788044-5a32c7078486?w=400',
|
||||
created_at: '2025-01-01',
|
||||
},
|
||||
];
|
||||
|
||||
export const mockBookings: Booking[] = [
|
||||
{
|
||||
id: 'bk-1',
|
||||
customer_id: 'cust-1',
|
||||
barber_id: 'barb-1',
|
||||
service_id: 'svc-1',
|
||||
booking_date: '2025-05-15',
|
||||
booking_time: '14:00',
|
||||
status: 'confirmed',
|
||||
service: mockServices[0],
|
||||
barber: mockBarbers[0],
|
||||
created_at: '2025-05-01',
|
||||
},
|
||||
{
|
||||
id: 'bk-2',
|
||||
customer_id: 'cust-1',
|
||||
barber_id: 'barb-2',
|
||||
service_id: 'svc-3',
|
||||
booking_date: '2025-05-10',
|
||||
booking_time: '10:00',
|
||||
status: 'completed',
|
||||
service: mockServices[2],
|
||||
barber: mockBarbers[1],
|
||||
created_at: '2025-04-25',
|
||||
},
|
||||
];
|
||||
184
src/navigation/AppNavigator.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import React from 'react';
|
||||
import { View, ActivityIndicator, Text } from 'react-native';
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { createStackNavigator } from '@react-navigation/stack';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { COLORS } from '../constants/theme';
|
||||
|
||||
// Import screens
|
||||
import LoginScreen from '../screens/auth/LoginScreen';
|
||||
import RegisterScreen from '../screens/auth/RegisterScreen';
|
||||
import HomeScreen from '../screens/HomeScreen';
|
||||
import ServicesScreen from '../screens/ServicesScreen';
|
||||
import BarbersScreen from '../screens/BarbersScreen';
|
||||
import BookingScreen from '../screens/BookingScreen';
|
||||
import ProfileScreen from '../screens/ProfileScreen';
|
||||
import AdminDashboardScreen from '../screens/admin/AdminDashboardScreen';
|
||||
import ServiceDetailScreen from '../screens/ServiceDetailScreen';
|
||||
import BarberDetailScreen from '../screens/BarberDetailScreen';
|
||||
import BookingConfirmationScreen from '../screens/BookingConfirmationScreen';
|
||||
|
||||
const Tab = createBottomTabNavigator();
|
||||
const Stack = createStackNavigator();
|
||||
|
||||
const MainTabNavigator = () => {
|
||||
return (
|
||||
// @ts-ignore - React Navigation type issue with RN Web
|
||||
<Tab.Navigator
|
||||
screenOptions={({ route }) => ({
|
||||
tabBarStyle: {
|
||||
backgroundColor: COLORS.surface,
|
||||
borderTopColor: COLORS.border,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 4,
|
||||
},
|
||||
tabBarActiveTintColor: COLORS.primary,
|
||||
tabBarInactiveTintColor: COLORS.textSecondary,
|
||||
headerStyle: {
|
||||
backgroundColor: COLORS.surface,
|
||||
borderBottomColor: COLORS.border,
|
||||
},
|
||||
headerTintColor: COLORS.text,
|
||||
tabBarIcon: ({ focused, color, size }) => {
|
||||
let iconName: keyof typeof Ionicons.glyphMap = 'home';
|
||||
if (route.name === 'Home') iconName = focused ? 'home' : 'home-outline';
|
||||
else if (route.name === 'Services') iconName = focused ? 'cut' : 'cut-outline';
|
||||
else if (route.name === 'Barbers') iconName = focused ? 'people' : 'people-outline';
|
||||
else if (route.name === 'Booking') iconName = focused ? 'calendar' : 'calendar-outline';
|
||||
else if (route.name === 'Profile') iconName = focused ? 'person' : 'person-outline';
|
||||
return <Ionicons name={iconName} size={size} color={color} />;
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Tab.Screen name="Home" component={HomeScreen} options={{ title: 'Início' }} />
|
||||
<Tab.Screen name="Services" component={ServicesScreen} options={{ title: 'Serviços' }} />
|
||||
<Tab.Screen name="Barbers" component={BarbersScreen} options={{ title: 'Barbeiros' }} />
|
||||
<Tab.Screen name="Booking" component={BookingScreen} options={{ title: 'Marcar' }} />
|
||||
<Tab.Screen name="Profile" component={ProfileScreen} options={{ title: 'Perfil' }} />
|
||||
</Tab.Navigator>
|
||||
);
|
||||
};
|
||||
|
||||
const AuthNavigator = () => {
|
||||
return (
|
||||
// @ts-ignore - React Navigation type issue with RN Web
|
||||
<Stack.Navigator
|
||||
screenOptions={{
|
||||
headerStyle: {
|
||||
backgroundColor: COLORS.surface,
|
||||
borderBottomColor: COLORS.border,
|
||||
},
|
||||
headerTintColor: COLORS.text,
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="Login" component={LoginScreen} />
|
||||
<Stack.Screen name="Register" component={RegisterScreen} />
|
||||
</Stack.Navigator>
|
||||
);
|
||||
};
|
||||
|
||||
const AppNavigator = () => {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={{
|
||||
flex: 1,
|
||||
backgroundColor: COLORS.background,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<Text style={{
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
color: COLORS.primary,
|
||||
marginBottom: 24,
|
||||
}}>
|
||||
AppBarber
|
||||
</Text>
|
||||
<ActivityIndicator size="large" color={COLORS.primary} />
|
||||
<Text style={{
|
||||
fontSize: 14,
|
||||
color: COLORS.textSecondary,
|
||||
marginTop: 16,
|
||||
}}>
|
||||
Experiência de Barbearia Premium
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavigationContainer>
|
||||
{/* @ts-ignore - React Navigation type issue with RN Web */}
|
||||
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||
{user ? (
|
||||
<>
|
||||
<Stack.Screen name="Main" component={MainTabNavigator} />
|
||||
<Stack.Screen
|
||||
name="ServiceDetail"
|
||||
component={ServiceDetailScreen}
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: 'Detalhes do Serviço',
|
||||
headerStyle: {
|
||||
backgroundColor: COLORS.surface,
|
||||
borderBottomColor: COLORS.border,
|
||||
},
|
||||
headerTintColor: COLORS.text,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="BarberDetail"
|
||||
component={BarberDetailScreen}
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: 'Perfil do Barbeiro',
|
||||
headerStyle: {
|
||||
backgroundColor: COLORS.surface,
|
||||
borderBottomColor: COLORS.border,
|
||||
},
|
||||
headerTintColor: COLORS.text,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="BookingConfirmation"
|
||||
component={BookingConfirmationScreen}
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: 'Confirmação',
|
||||
headerStyle: {
|
||||
backgroundColor: COLORS.surface,
|
||||
borderBottomColor: COLORS.border,
|
||||
},
|
||||
headerTintColor: COLORS.text,
|
||||
}}
|
||||
/>
|
||||
{user.role === 'admin' && (
|
||||
<Stack.Screen
|
||||
name="AdminDashboard"
|
||||
component={AdminDashboardScreen}
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: 'Painel Admin',
|
||||
headerStyle: {
|
||||
backgroundColor: COLORS.surface,
|
||||
borderBottomColor: COLORS.border,
|
||||
},
|
||||
headerTintColor: COLORS.text,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Stack.Screen name="Auth" component={AuthNavigator} />
|
||||
)}
|
||||
</Stack.Navigator>
|
||||
</NavigationContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppNavigator;
|
||||
34
src/navigation/types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export type RootStackParamList = {
|
||||
Main: undefined;
|
||||
Auth: undefined;
|
||||
Login: undefined;
|
||||
Register: undefined;
|
||||
Home: undefined;
|
||||
Services: undefined;
|
||||
Barbers: undefined;
|
||||
Booking: undefined;
|
||||
Profile: undefined;
|
||||
ServiceDetail: { serviceId: string };
|
||||
BarberDetail: { barberId: string };
|
||||
AdminDashboard: undefined;
|
||||
BookingConfirmation: {
|
||||
serviceName: string;
|
||||
barberName: string;
|
||||
date: string;
|
||||
time: string;
|
||||
price: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type TabParamList = {
|
||||
Home: undefined;
|
||||
Services: undefined;
|
||||
Barbers: undefined;
|
||||
Booking: undefined;
|
||||
Profile: undefined;
|
||||
};
|
||||
|
||||
export type AuthParamList = {
|
||||
Login: undefined;
|
||||
Register: undefined;
|
||||
};
|
||||
470
src/screens/BarberDetailScreen.tsx
Normal file
@@ -0,0 +1,470 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
Image,
|
||||
Alert,
|
||||
FlatList,
|
||||
} from 'react-native';
|
||||
import { useRoute, useNavigation } from '@react-navigation/native';
|
||||
import { StackNavigationProp } from '@react-navigation/stack';
|
||||
import { RootStackParamList } from '../navigation/types';
|
||||
import { supabase, isSupabaseConfigured } from '../services/supabase';
|
||||
import { COLORS, SIZES, FONTS, SHADOWS } from '../constants/theme';
|
||||
import { Barber, Review, Booking } from '../types';
|
||||
import ReviewCard from '../components/ReviewCard';
|
||||
import BookingCard from '../components/BookingCard';
|
||||
|
||||
const BarberDetailScreen: React.FC = () => {
|
||||
const route = useRoute();
|
||||
const navigation = useNavigation<StackNavigationProp<RootStackParamList>>();
|
||||
const { barberId } = route.params as { barberId: string };
|
||||
|
||||
const [barber, setBarber] = useState<Barber | null>(null);
|
||||
const [reviews, setReviews] = useState<Review[]>([]);
|
||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<'reviews' | 'availability'>('reviews');
|
||||
|
||||
useEffect(() => {
|
||||
loadBarberDetails();
|
||||
}, [barberId]);
|
||||
|
||||
const loadBarberDetails = async () => {
|
||||
try {
|
||||
if (!isSupabaseConfigured) {
|
||||
// Demo mode - use mock data
|
||||
const { mockBarbers, mockReviews } = await import('../data/mockData');
|
||||
const barber = mockBarbers.find(b => b.id === barberId);
|
||||
if (barber) {
|
||||
setBarber(barber);
|
||||
// Load barber-specific reviews
|
||||
const barberReviews = mockReviews.filter(r => r.barber_id === barberId);
|
||||
setReviews(barberReviews);
|
||||
// Mock bookings for demo
|
||||
setBookings([]);
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const [barberRes, reviewsRes, bookingsRes] = await Promise.all([
|
||||
supabase
|
||||
.from('barbers')
|
||||
.select(`
|
||||
*,
|
||||
user:users(name, photo, email, phone)
|
||||
`)
|
||||
.eq('id', barberId)
|
||||
.single(),
|
||||
supabase
|
||||
.from('reviews')
|
||||
.select(`
|
||||
*,
|
||||
user:users(name, photo)
|
||||
`)
|
||||
.eq('barber_id', barberId)
|
||||
.order('created_at', { ascending: false }),
|
||||
supabase
|
||||
.from('bookings')
|
||||
.select(`
|
||||
*,
|
||||
service:services(name, price),
|
||||
customer:users(name)
|
||||
`)
|
||||
.eq('barber_id', barberId)
|
||||
.eq('status', 'completed')
|
||||
.order('booking_date', { ascending: false })
|
||||
.limit(10)
|
||||
]);
|
||||
|
||||
if (barberRes.data) setBarber(barberRes.data);
|
||||
if (reviewsRes.data) setReviews(reviewsRes.data);
|
||||
if (bookingsRes.data) setBookings(bookingsRes.data);
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar detalhes do barbeiro:', error);
|
||||
Alert.alert('Erro', 'Falha ao carregar detalhes do barbeiro');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBookNow = () => {
|
||||
Alert.alert(
|
||||
'👨💼 Marcar com Barbeiro',
|
||||
`Vou marcar com: ${barber?.user?.name}`,
|
||||
[
|
||||
{
|
||||
text: 'Cancelar',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Confirmar',
|
||||
onPress: () => {
|
||||
Alert.alert(
|
||||
'✅ Barbeiro Selecionado',
|
||||
`${barber?.user?.name} foi selecionado para a sua marcação.\n\nContinue para escolher serviço, data e horário.`,
|
||||
[
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => navigation.navigate('Booking'),
|
||||
},
|
||||
]
|
||||
);
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const renderStars = (rating: number) => {
|
||||
const stars = [];
|
||||
const fullStars = Math.floor(rating);
|
||||
|
||||
for (let i = 0; i < fullStars; i++) {
|
||||
stars.push(
|
||||
<Text key={i} style={styles.star}>★</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyStars = 5 - fullStars;
|
||||
for (let i = 0; i < emptyStars; i++) {
|
||||
stars.push(
|
||||
<Text key={`empty-${i}`} style={[styles.star, styles.emptyStar]}>☆</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return stars;
|
||||
};
|
||||
|
||||
const renderAvailability = () => {
|
||||
if (!barber?.availability) return null;
|
||||
|
||||
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
|
||||
return (
|
||||
<View style={styles.availabilityContainer}>
|
||||
{days.map((day) => {
|
||||
const timeSlots = barber.availability[day.toLowerCase()] || [];
|
||||
return (
|
||||
<View key={day} style={styles.dayRow}>
|
||||
<Text style={styles.dayText}>{day}</Text>
|
||||
<View style={styles.timeSlots}>
|
||||
{timeSlots.length > 0 ? (
|
||||
timeSlots.map((time, index) => (
|
||||
<Text key={index} style={styles.timeSlot}>
|
||||
{time}
|
||||
</Text>
|
||||
))
|
||||
) : (
|
||||
<Text style={styles.closedText}>Fechado</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.loadingText}>A carregar...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!barber) {
|
||||
return (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>Barbeiro não encontrado</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
{/* Barber Header */}
|
||||
<View style={styles.header}>
|
||||
<Image
|
||||
source={{ uri: barber.user?.photo || 'https://via.placeholder.com/150' }}
|
||||
style={styles.avatar}
|
||||
/>
|
||||
<View style={styles.barberInfo}>
|
||||
<Text style={styles.name}>{barber.user?.name}</Text>
|
||||
<Text style={styles.specialty}>{barber.specialty}</Text>
|
||||
<View style={styles.ratingContainer}>
|
||||
<View style={styles.stars}>
|
||||
{renderStars(barber.rating)}
|
||||
</View>
|
||||
<Text style={styles.rating}>{barber.rating.toFixed(1)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Bio */}
|
||||
<View style={styles.bioSection}>
|
||||
<Text style={styles.sectionTitle}>Sobre</Text>
|
||||
<Text style={styles.bio}>{barber.bio}</Text>
|
||||
</View>
|
||||
|
||||
{/* Contact Info */}
|
||||
<View style={styles.contactSection}>
|
||||
<Text style={styles.sectionTitle}>Contacto</Text>
|
||||
<Text style={styles.contactText}>📧 {barber.user?.email}</Text>
|
||||
<Text style={styles.contactText}>📱 {barber.user?.phone}</Text>
|
||||
</View>
|
||||
|
||||
{/* Book Button */}
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.bookButton, pressed && styles.bookButtonPressed]}
|
||||
onPress={handleBookNow}
|
||||
>
|
||||
<Text style={styles.bookButtonText}>Marcar Horário</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* Tabs */}
|
||||
<View style={styles.tabsContainer}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.tab,
|
||||
activeTab === 'reviews' && styles.activeTab,
|
||||
pressed && styles.tabPressed
|
||||
]}
|
||||
onPress={() => setActiveTab('reviews')}
|
||||
>
|
||||
<Text style={[styles.tabText, activeTab === 'reviews' && styles.activeTabText]}>
|
||||
Avaliações ({reviews.length})
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.tab,
|
||||
activeTab === 'availability' && styles.activeTab,
|
||||
pressed && styles.tabPressed
|
||||
]}
|
||||
onPress={() => setActiveTab('availability')}
|
||||
>
|
||||
<Text style={[styles.tabText, activeTab === 'availability' && styles.activeTabText]}>
|
||||
Disponibilidade
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Tab Content */}
|
||||
<View style={styles.tabContent}>
|
||||
{activeTab === 'reviews' ? (
|
||||
<>
|
||||
{reviews.length === 0 ? (
|
||||
<Text style={styles.noReviewsText}>Sem avaliações</Text>
|
||||
) : (
|
||||
reviews.map((review) => (
|
||||
<ReviewCard key={review.id} review={review} />
|
||||
))
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
renderAvailability()
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: COLORS.background,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.textSecondary,
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
errorText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.error,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
padding: SIZES.padding,
|
||||
backgroundColor: COLORS.surface,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.border,
|
||||
},
|
||||
avatar: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
marginRight: SIZES.margin,
|
||||
},
|
||||
barberInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
name: {
|
||||
...FONTS.h1,
|
||||
color: COLORS.text,
|
||||
marginBottom: SIZES.base / 2,
|
||||
},
|
||||
specialty: {
|
||||
...FONTS.h3,
|
||||
color: COLORS.primary,
|
||||
marginBottom: SIZES.base,
|
||||
},
|
||||
ratingContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
stars: {
|
||||
flexDirection: 'row',
|
||||
marginRight: SIZES.base,
|
||||
},
|
||||
star: {
|
||||
color: COLORS.primary,
|
||||
fontSize: 18,
|
||||
},
|
||||
emptyStar: {
|
||||
color: COLORS.border,
|
||||
},
|
||||
rating: {
|
||||
...FONTS.h3,
|
||||
color: COLORS.text,
|
||||
},
|
||||
bioSection: {
|
||||
padding: SIZES.padding,
|
||||
},
|
||||
sectionTitle: {
|
||||
...FONTS.h2,
|
||||
color: COLORS.text,
|
||||
marginBottom: SIZES.margin,
|
||||
},
|
||||
bio: {
|
||||
...FONTS.body,
|
||||
color: COLORS.textSecondary,
|
||||
lineHeight: 24,
|
||||
},
|
||||
contactSection: {
|
||||
padding: SIZES.padding,
|
||||
backgroundColor: COLORS.surface,
|
||||
margin: SIZES.margin,
|
||||
borderRadius: SIZES.radius,
|
||||
...SHADOWS.light,
|
||||
},
|
||||
contactText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.text,
|
||||
marginBottom: SIZES.base,
|
||||
},
|
||||
bookButton: {
|
||||
backgroundColor: COLORS.primary,
|
||||
borderRadius: SIZES.radius,
|
||||
padding: SIZES.padding,
|
||||
alignItems: 'center',
|
||||
margin: SIZES.margin,
|
||||
...SHADOWS.medium,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
bookButtonPressed: {
|
||||
opacity: 0.9,
|
||||
},
|
||||
bookButtonText: {
|
||||
...FONTS.h3,
|
||||
color: COLORS.background,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
tabsContainer: {
|
||||
flexDirection: 'row',
|
||||
marginHorizontal: SIZES.margin,
|
||||
marginBottom: SIZES.margin,
|
||||
backgroundColor: COLORS.surface,
|
||||
borderRadius: SIZES.radius,
|
||||
padding: SIZES.base,
|
||||
...SHADOWS.light,
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
paddingVertical: SIZES.base,
|
||||
alignItems: 'center',
|
||||
borderRadius: SIZES.base,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
tabPressed: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
activeTab: {
|
||||
backgroundColor: COLORS.primary,
|
||||
},
|
||||
tabText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.textSecondary,
|
||||
},
|
||||
activeTabText: {
|
||||
color: COLORS.background,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
tabContent: {
|
||||
marginHorizontal: SIZES.margin,
|
||||
marginBottom: SIZES.margin * 2,
|
||||
},
|
||||
noReviewsText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.textSecondary,
|
||||
textAlign: 'center',
|
||||
paddingVertical: SIZES.padding,
|
||||
},
|
||||
availabilityContainer: {
|
||||
backgroundColor: COLORS.surface,
|
||||
borderRadius: SIZES.radius,
|
||||
padding: SIZES.padding,
|
||||
...SHADOWS.light,
|
||||
},
|
||||
dayRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: SIZES.base,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.border,
|
||||
},
|
||||
dayText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.text,
|
||||
flex: 1,
|
||||
},
|
||||
timeSlots: {
|
||||
flex: 2,
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
timeSlot: {
|
||||
...FONTS.caption,
|
||||
color: COLORS.primary,
|
||||
backgroundColor: COLORS.background,
|
||||
paddingHorizontal: SIZES.base,
|
||||
paddingVertical: SIZES.base / 2,
|
||||
borderRadius: SIZES.base / 2,
|
||||
marginLeft: SIZES.base / 2,
|
||||
marginBottom: SIZES.base / 2,
|
||||
},
|
||||
closedText: {
|
||||
...FONTS.caption,
|
||||
color: COLORS.textSecondary,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
});
|
||||
|
||||
export default BarberDetailScreen;
|
||||
158
src/screens/BarbersScreen.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
RefreshControl,
|
||||
TextInput,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { StackNavigationProp } from '@react-navigation/stack';
|
||||
import { RootStackParamList } from '../navigation/types';
|
||||
import { supabase, isSupabaseConfigured } from '../services/supabase';
|
||||
import { mockBarbers } from '../data/mockData';
|
||||
import { COLORS, SIZES, FONTS } from '../constants/theme';
|
||||
import { Barber } from '../types';
|
||||
import BarberCard from '../components/BarberCard';
|
||||
|
||||
const BarbersScreen: React.FC = () => {
|
||||
const navigation = useNavigation<StackNavigationProp<RootStackParamList>>();
|
||||
const [barbers, setBarbers] = useState<Barber[]>([]);
|
||||
const [filteredBarbers, setFilteredBarbers] = useState<Barber[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadBarbers();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchQuery) {
|
||||
const filtered = barbers.filter(barber =>
|
||||
barber.user?.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
barber.specialty.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
setFilteredBarbers(filtered);
|
||||
} else {
|
||||
setFilteredBarbers(barbers);
|
||||
}
|
||||
}, [searchQuery, barbers]);
|
||||
|
||||
const loadBarbers = async () => {
|
||||
try {
|
||||
if (!isSupabaseConfigured) {
|
||||
setBarbers(mockBarbers);
|
||||
setFilteredBarbers(mockBarbers);
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('barbers')
|
||||
.select(`
|
||||
*,
|
||||
user:users(name, photo)
|
||||
`)
|
||||
.eq('is_active', true)
|
||||
.order('rating', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
setBarbers(data || []);
|
||||
setFilteredBarbers(data || []);
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar barbeiros:', error);
|
||||
setBarbers(mockBarbers);
|
||||
setFilteredBarbers(mockBarbers);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onRefresh = () => {
|
||||
setRefreshing(true);
|
||||
loadBarbers();
|
||||
};
|
||||
|
||||
const renderBarber = ({ item }: { item: Barber }) => (
|
||||
<BarberCard
|
||||
barber={item}
|
||||
onPress={() => navigation.navigate('BarberDetail', { barberId: item.id })}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Os Nossos Barbeiros</Text>
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder="Pesquisar barbeiros..."
|
||||
placeholderTextColor={COLORS.textSecondary}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={filteredBarbers}
|
||||
renderItem={renderBarber}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.list}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={COLORS.primary} />
|
||||
}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>Nenhum barbeiro encontrado</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: COLORS.background,
|
||||
},
|
||||
header: {
|
||||
padding: SIZES.padding,
|
||||
backgroundColor: COLORS.surface,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.border,
|
||||
},
|
||||
title: {
|
||||
...FONTS.h2,
|
||||
color: COLORS.text,
|
||||
marginBottom: SIZES.margin,
|
||||
},
|
||||
searchInput: {
|
||||
backgroundColor: COLORS.background,
|
||||
borderRadius: SIZES.radius,
|
||||
padding: SIZES.padding,
|
||||
color: COLORS.text,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.border,
|
||||
},
|
||||
list: {
|
||||
padding: SIZES.margin,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: SIZES.padding * 4,
|
||||
},
|
||||
emptyText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.textSecondary,
|
||||
},
|
||||
});
|
||||
|
||||
export default BarbersScreen;
|
||||
212
src/screens/BookingConfirmationScreen.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { useRoute, useNavigation } from '@react-navigation/native';
|
||||
import { StackNavigationProp } from '@react-navigation/stack';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { RootStackParamList } from '../navigation/types';
|
||||
import { COLORS, SIZES, FONTS, SHADOWS } from '../constants/theme';
|
||||
|
||||
const BookingConfirmationScreen: React.FC = () => {
|
||||
const route = useRoute();
|
||||
const navigation = useNavigation<StackNavigationProp<RootStackParamList>>();
|
||||
const { serviceName, barberName, date, time, price } = route.params as {
|
||||
serviceName: string;
|
||||
barberName: string;
|
||||
date: string;
|
||||
time: string;
|
||||
price: number;
|
||||
};
|
||||
|
||||
const formattedDate = new Date(date).toLocaleDateString('pt-PT', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
<View style={styles.successIcon}>
|
||||
<Ionicons name="checkmark-circle" size={80} color={COLORS.primary} />
|
||||
</View>
|
||||
|
||||
<Text style={styles.title}>Marcação Confirmada!</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
A sua marcação foi realizada com sucesso.
|
||||
</Text>
|
||||
|
||||
<View style={styles.card}>
|
||||
<View style={styles.detailRow}>
|
||||
<Ionicons name="cut-outline" size={20} color={COLORS.primary} />
|
||||
<View style={styles.detailText}>
|
||||
<Text style={styles.detailLabel}>Serviço</Text>
|
||||
<Text style={styles.detailValue}>{serviceName}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<View style={styles.detailRow}>
|
||||
<Ionicons name="person-outline" size={20} color={COLORS.primary} />
|
||||
<View style={styles.detailText}>
|
||||
<Text style={styles.detailLabel}>Barbeiro</Text>
|
||||
<Text style={styles.detailValue}>{barberName}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<View style={styles.detailRow}>
|
||||
<Ionicons name="calendar-outline" size={20} color={COLORS.primary} />
|
||||
<View style={styles.detailText}>
|
||||
<Text style={styles.detailLabel}>Data</Text>
|
||||
<Text style={styles.detailValue}>{formattedDate}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<View style={styles.detailRow}>
|
||||
<Ionicons name="time-outline" size={20} color={COLORS.primary} />
|
||||
<View style={styles.detailText}>
|
||||
<Text style={styles.detailLabel}>Horário</Text>
|
||||
<Text style={styles.detailValue}>{time}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<View style={styles.detailRow}>
|
||||
<Ionicons name="cash-outline" size={20} color={COLORS.primary} />
|
||||
<View style={styles.detailText}>
|
||||
<Text style={styles.detailLabel}>Preço</Text>
|
||||
<Text style={styles.priceValue}>€{price}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.primaryButton, pressed && styles.primaryButtonPressed]}
|
||||
onPress={() => navigation.navigate('Main')}
|
||||
>
|
||||
<Text style={styles.primaryButtonText}>Voltar ao Início</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.secondaryButton, pressed && styles.secondaryButtonPressed]}
|
||||
onPress={() => navigation.navigate('Profile')}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>Ver as Minhas Marcações</Text>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: COLORS.background,
|
||||
},
|
||||
content: {
|
||||
padding: SIZES.padding * 2,
|
||||
alignItems: 'center',
|
||||
paddingTop: SIZES.padding * 3,
|
||||
paddingBottom: SIZES.padding * 4,
|
||||
},
|
||||
successIcon: {
|
||||
marginBottom: SIZES.margin * 2,
|
||||
},
|
||||
title: {
|
||||
...FONTS.h2,
|
||||
color: COLORS.text,
|
||||
textAlign: 'center',
|
||||
marginBottom: SIZES.base,
|
||||
},
|
||||
subtitle: {
|
||||
...FONTS.body,
|
||||
color: COLORS.textSecondary,
|
||||
textAlign: 'center',
|
||||
marginBottom: SIZES.margin * 2,
|
||||
},
|
||||
card: {
|
||||
width: '100%',
|
||||
backgroundColor: COLORS.surface,
|
||||
borderRadius: SIZES.radius,
|
||||
padding: SIZES.padding * 1.5,
|
||||
marginBottom: SIZES.margin * 2,
|
||||
...SHADOWS.medium,
|
||||
},
|
||||
detailRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: SIZES.base,
|
||||
},
|
||||
detailText: {
|
||||
marginLeft: SIZES.margin,
|
||||
flex: 1,
|
||||
},
|
||||
detailLabel: {
|
||||
...FONTS.caption,
|
||||
color: COLORS.textSecondary,
|
||||
marginBottom: 2,
|
||||
},
|
||||
detailValue: {
|
||||
...FONTS.body,
|
||||
color: COLORS.text,
|
||||
fontWeight: '600',
|
||||
},
|
||||
priceValue: {
|
||||
...FONTS.h3,
|
||||
color: COLORS.primary,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: COLORS.border,
|
||||
marginLeft: 36,
|
||||
},
|
||||
primaryButton: {
|
||||
width: '100%',
|
||||
backgroundColor: COLORS.primary,
|
||||
borderRadius: SIZES.radius,
|
||||
padding: SIZES.padding,
|
||||
alignItems: 'center',
|
||||
marginBottom: SIZES.margin,
|
||||
...SHADOWS.light,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
primaryButtonPressed: {
|
||||
opacity: 0.8,
|
||||
},
|
||||
primaryButtonText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.background,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
secondaryButton: {
|
||||
width: '100%',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: SIZES.radius,
|
||||
padding: SIZES.padding,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.primary,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
secondaryButtonPressed: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
secondaryButtonText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.primary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default BookingConfirmationScreen;
|
||||
714
src/screens/BookingScreen.tsx
Normal file
@@ -0,0 +1,714 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { StackNavigationProp } from '@react-navigation/stack';
|
||||
import { RootStackParamList } from '../navigation/types';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { supabase, isSupabaseConfigured } from '../services/supabase';
|
||||
import { localDatabase } from '../services/localDatabase';
|
||||
import { COLORS, SIZES, FONTS, SHADOWS } from '../constants/theme';
|
||||
import { Service, Barber, Booking } from '../types';
|
||||
import { mockServices, mockBarbers, mockBookings } from '../data/mockData';
|
||||
|
||||
interface BookingStep {
|
||||
selectedService: Service | null;
|
||||
selectedBarber: Barber | null;
|
||||
selectedDate: string;
|
||||
selectedTime: string;
|
||||
}
|
||||
|
||||
const BookingScreen: React.FC = () => {
|
||||
const navigation = useNavigation<StackNavigationProp<RootStackParamList>>();
|
||||
const { user } = useAuth();
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [barbers, setBarbers] = useState<Barber[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [bookingData, setBookingData] = useState<BookingStep>({
|
||||
selectedService: null,
|
||||
selectedBarber: null,
|
||||
selectedDate: '',
|
||||
selectedTime: '',
|
||||
});
|
||||
|
||||
const steps = ['Serviço', 'Barbeiro', 'Data', 'Horário', 'Confirmar'];
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
if (!isSupabaseConfigured) {
|
||||
setServices(mockServices);
|
||||
setBarbers(mockBarbers);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const [servicesRes, barbersRes] = await Promise.all([
|
||||
supabase
|
||||
.from('services')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
.order('name'),
|
||||
supabase
|
||||
.from('barbers')
|
||||
.select(`
|
||||
*,
|
||||
user:users(name, photo)
|
||||
`)
|
||||
.eq('is_active', true)
|
||||
.order('rating', { ascending: false }),
|
||||
]);
|
||||
|
||||
if (servicesRes.data) setServices(servicesRes.data);
|
||||
if (barbersRes.data) setBarbers(barbersRes.data);
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar dados:', error);
|
||||
setServices(mockServices);
|
||||
setBarbers(mockBarbers);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkAvailability = async (barberId: string, date: string, time: string) => {
|
||||
try {
|
||||
if (!isSupabaseConfigured) {
|
||||
// Demo mode - check against mock bookings
|
||||
const conflict = mockBookings.find(
|
||||
b => b.barber_id === barberId && b.booking_date === date && b.booking_time === time
|
||||
);
|
||||
return !conflict;
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('bookings')
|
||||
.select('*')
|
||||
.eq('barber_id', barberId)
|
||||
.eq('booking_date', date)
|
||||
.eq('booking_time', time)
|
||||
.neq('status', 'cancelled');
|
||||
|
||||
if (error) throw error;
|
||||
return (data || []).length === 0;
|
||||
} catch (error) {
|
||||
console.error('Erro ao verificar disponibilidade:', error);
|
||||
return true; // Allow booking on error
|
||||
}
|
||||
};
|
||||
|
||||
const generateTimeSlots = () => {
|
||||
const slots = [];
|
||||
for (let hour = 9; hour <= 18; hour++) {
|
||||
for (let minute = 0; minute < 60; minute += 30) {
|
||||
const time = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
|
||||
slots.push(time);
|
||||
}
|
||||
}
|
||||
return slots;
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
if (currentStep === 0 && !bookingData.selectedService) {
|
||||
Alert.alert('Erro', 'Seleccione um serviço');
|
||||
return;
|
||||
}
|
||||
if (currentStep === 1 && !bookingData.selectedBarber) {
|
||||
Alert.alert('Erro', 'Seleccione um barbeiro');
|
||||
return;
|
||||
}
|
||||
if (currentStep === 2 && !bookingData.selectedDate) {
|
||||
Alert.alert('Erro', 'Seleccione uma data');
|
||||
return;
|
||||
}
|
||||
if (currentStep === 3 && !bookingData.selectedTime) {
|
||||
Alert.alert('Erro', 'Seleccione um horário');
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentStep < steps.length - 1) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
} else {
|
||||
await submitBooking();
|
||||
}
|
||||
};
|
||||
|
||||
const submitBooking = async () => {
|
||||
if (!user) {
|
||||
Alert.alert('Erro', 'Faça login para marcar');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const isAvailable = await checkAvailability(
|
||||
bookingData.selectedBarber!.id,
|
||||
bookingData.selectedDate,
|
||||
bookingData.selectedTime
|
||||
);
|
||||
|
||||
if (!isAvailable) {
|
||||
Alert.alert('Erro', 'Este horário não está mais disponível. Escolha outro horário.');
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSupabaseConfigured) {
|
||||
// Demo mode - save to local database
|
||||
await localDatabase.initialize();
|
||||
|
||||
const newBooking = await localDatabase.createBooking({
|
||||
customer_id: user.id,
|
||||
barber_id: bookingData.selectedBarber!.id,
|
||||
service_id: bookingData.selectedService!.id,
|
||||
booking_date: bookingData.selectedDate,
|
||||
booking_time: bookingData.selectedTime,
|
||||
status: 'confirmed',
|
||||
});
|
||||
|
||||
setSubmitting(false);
|
||||
|
||||
// Show success notification
|
||||
Alert.alert(
|
||||
'✅ Marcação Confirmada!',
|
||||
`A sua marcação foi agendada com sucesso:\n\n` +
|
||||
`📅 Data: ${new Date(bookingData.selectedDate).toLocaleDateString('pt-PT')}\n` +
|
||||
`⏰ Hora: ${bookingData.selectedTime}\n` +
|
||||
`💇 Serviço: ${bookingData.selectedService?.name}\n` +
|
||||
`👨💼 Barbeiro: ${bookingData.selectedBarber?.user?.name}\n` +
|
||||
`💰 Preço: €${bookingData.selectedService?.price}`,
|
||||
[
|
||||
{
|
||||
text: 'Ver Minhas Marcações',
|
||||
onPress: () => navigation.navigate('Profile'),
|
||||
},
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => {
|
||||
navigation.navigate('BookingConfirmation', {
|
||||
serviceName: bookingData.selectedService?.name || '',
|
||||
barberName: bookingData.selectedBarber?.user?.name || '',
|
||||
date: bookingData.selectedDate,
|
||||
time: bookingData.selectedTime,
|
||||
price: bookingData.selectedService?.price || 0,
|
||||
});
|
||||
setBookingData({
|
||||
selectedService: null,
|
||||
selectedBarber: null,
|
||||
selectedDate: '',
|
||||
selectedTime: '',
|
||||
});
|
||||
setCurrentStep(0);
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('bookings')
|
||||
.insert([
|
||||
{
|
||||
customer_id: user.id,
|
||||
barber_id: bookingData.selectedBarber!.id,
|
||||
service_id: bookingData.selectedService!.id,
|
||||
booking_date: bookingData.selectedDate,
|
||||
booking_time: bookingData.selectedTime,
|
||||
status: 'pending',
|
||||
},
|
||||
])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
setSubmitting(false);
|
||||
|
||||
// Show success notification
|
||||
Alert.alert(
|
||||
'✅ Marcação Confirmada!',
|
||||
`A sua marcação foi agendada com sucesso:\n\n` +
|
||||
`📅 Data: ${new Date(bookingData.selectedDate).toLocaleDateString('pt-PT')}\n` +
|
||||
`⏰ Hora: ${bookingData.selectedTime}\n` +
|
||||
`💇 Serviço: ${bookingData.selectedService?.name}\n` +
|
||||
`👨💼 Barbeiro: ${bookingData.selectedBarber?.user?.name}\n` +
|
||||
`💰 Preço: €${bookingData.selectedService?.price}`,
|
||||
[
|
||||
{
|
||||
text: 'Ver Minhas Marcações',
|
||||
onPress: () => navigation.navigate('Profile'),
|
||||
},
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => {
|
||||
navigation.navigate('BookingConfirmation', {
|
||||
serviceName: bookingData.selectedService?.name || '',
|
||||
barberName: bookingData.selectedBarber?.user?.name || '',
|
||||
date: bookingData.selectedDate,
|
||||
time: bookingData.selectedTime,
|
||||
price: bookingData.selectedService?.price || 0,
|
||||
});
|
||||
setBookingData({
|
||||
selectedService: null,
|
||||
selectedBarber: null,
|
||||
selectedDate: '',
|
||||
selectedTime: '',
|
||||
});
|
||||
setCurrentStep(0);
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
return;
|
||||
} catch (error: any) {
|
||||
Alert.alert('Erro', error.message || 'Falha ao confirmar marcação');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderServiceSelection = () => (
|
||||
<ScrollView style={styles.stepContent}>
|
||||
<Text style={styles.stepTitle}>Seleccione um Serviço</Text>
|
||||
{services.map((service) => (
|
||||
<Pressable
|
||||
key={service.id}
|
||||
style={({ pressed }) => [
|
||||
styles.optionCard,
|
||||
bookingData.selectedService?.id === service.id && styles.selectedCard,
|
||||
pressed && styles.optionCardPressed
|
||||
]}
|
||||
onPress={() => setBookingData({ ...bookingData, selectedService: service })}
|
||||
>
|
||||
<Text style={styles.optionTitle}>{service.name}</Text>
|
||||
<Text style={styles.optionDescription}>{service.description}</Text>
|
||||
<View style={styles.optionFooter}>
|
||||
<Text style={styles.optionPrice}>€{service.price}</Text>
|
||||
<Text style={styles.optionDuration}>{service.duration} min</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
const renderBarberSelection = () => (
|
||||
<ScrollView style={styles.stepContent}>
|
||||
<Text style={styles.stepTitle}>Seleccione um Barbeiro</Text>
|
||||
{barbers.map((barber) => (
|
||||
<Pressable
|
||||
key={barber.id}
|
||||
style={({ pressed }) => [
|
||||
styles.optionCard,
|
||||
bookingData.selectedBarber?.id === barber.id && styles.selectedCard,
|
||||
pressed && styles.optionCardPressed
|
||||
]}
|
||||
onPress={() => setBookingData({ ...bookingData, selectedBarber: barber })}
|
||||
>
|
||||
<Text style={styles.optionTitle}>{barber.user?.name}</Text>
|
||||
<Text style={styles.optionDescription}>{barber.specialty}</Text>
|
||||
<Text style={styles.optionRating}>Avaliação: {barber.rating.toFixed(1)} ★</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
const renderDateSelection = () => (
|
||||
<ScrollView style={styles.stepContent}>
|
||||
<Text style={styles.stepTitle}>Seleccione uma Data</Text>
|
||||
{Array.from({ length: 14 }, (_, i) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + i);
|
||||
const dateString = date.toISOString().split('T')[0];
|
||||
const isToday = i === 0;
|
||||
const isWeekend = date.getDay() === 0 || date.getDay() === 6;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={dateString}
|
||||
style={({ pressed }) => [
|
||||
styles.optionCard,
|
||||
bookingData.selectedDate === dateString && styles.selectedCard,
|
||||
isWeekend && styles.weekendCard,
|
||||
pressed && styles.optionCardPressed
|
||||
]}
|
||||
onPress={() => setBookingData({ ...bookingData, selectedDate: dateString })}
|
||||
>
|
||||
<Text style={styles.optionTitle}>
|
||||
{date.toLocaleDateString('pt-PT', {
|
||||
weekday: 'long',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</Text>
|
||||
{isToday && <Text style={styles.todayBadge}>Hoje</Text>}
|
||||
{isWeekend && <Text style={styles.weekendBadge}>Fim de Semana</Text>}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
const renderTimeSelection = () => (
|
||||
<ScrollView style={styles.stepContent}>
|
||||
<Text style={styles.stepTitle}>Seleccione um Horário</Text>
|
||||
<View style={styles.timeGrid}>
|
||||
{generateTimeSlots().map((time) => (
|
||||
<Pressable
|
||||
key={time}
|
||||
style={({ pressed }) => [
|
||||
styles.timeSlot,
|
||||
bookingData.selectedTime === time && styles.selectedTimeSlot,
|
||||
pressed && styles.timeSlotPressed
|
||||
]}
|
||||
onPress={() => setBookingData({ ...bookingData, selectedTime: time })}
|
||||
>
|
||||
<Text style={styles.timeText}>{time}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
const renderConfirmation = () => (
|
||||
<ScrollView style={styles.stepContent}>
|
||||
<Text style={styles.stepTitle}>Confirme a sua Marcação</Text>
|
||||
<View style={styles.confirmationCard}>
|
||||
<Text style={styles.confirmationTitle}>Serviço</Text>
|
||||
<Text style={styles.confirmationValue}>{bookingData.selectedService?.name}</Text>
|
||||
|
||||
<Text style={styles.confirmationTitle}>Barbeiro</Text>
|
||||
<Text style={styles.confirmationValue}>{bookingData.selectedBarber?.user?.name}</Text>
|
||||
|
||||
<Text style={styles.confirmationTitle}>Data</Text>
|
||||
<Text style={styles.confirmationValue}>
|
||||
{new Date(bookingData.selectedDate).toLocaleDateString('pt-PT', {
|
||||
weekday: 'long',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<Text style={styles.confirmationTitle}>Horário</Text>
|
||||
<Text style={styles.confirmationValue}>{bookingData.selectedTime}</Text>
|
||||
|
||||
<Text style={styles.confirmationTitle}>Preço</Text>
|
||||
<Text style={styles.confirmationPrice}>€{bookingData.selectedService?.price}</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
const renderStep = () => {
|
||||
switch (currentStep) {
|
||||
case 0:
|
||||
return renderServiceSelection();
|
||||
case 1:
|
||||
return renderBarberSelection();
|
||||
case 2:
|
||||
return renderDateSelection();
|
||||
case 3:
|
||||
return renderTimeSelection();
|
||||
case 4:
|
||||
return renderConfirmation();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={COLORS.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.progressContainer}>
|
||||
{steps.map((step, index) => (
|
||||
<View key={step} style={styles.progressStep}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressDot,
|
||||
index <= currentStep && styles.activeDot,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.progressText,
|
||||
index <= currentStep && styles.activeProgressText,
|
||||
]}
|
||||
>
|
||||
{index + 1}
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
styles.progressLabel,
|
||||
index <= currentStep && styles.activeProgressLabel,
|
||||
]}
|
||||
>
|
||||
{step}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{renderStep()}
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.backButton,
|
||||
currentStep === 0 && styles.disabledButton,
|
||||
pressed && styles.backButtonPressed
|
||||
]}
|
||||
onPress={() => setCurrentStep(Math.max(0, currentStep - 1))}
|
||||
disabled={currentStep === 0}
|
||||
>
|
||||
<Text style={styles.backButtonText}>Voltar</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.nextButton,
|
||||
submitting && styles.disabledButton,
|
||||
pressed && styles.nextButtonPressed
|
||||
]}
|
||||
onPress={handleNext}
|
||||
disabled={submitting}
|
||||
>
|
||||
<Text style={styles.nextButtonText}>
|
||||
{submitting ? 'Agendando...' : currentStep === steps.length - 1 ? 'Confirmar Agendamento' : 'Próximo'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: COLORS.background,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
progressContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
padding: SIZES.padding,
|
||||
backgroundColor: COLORS.surface,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.border,
|
||||
},
|
||||
progressStep: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
progressDot: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 15,
|
||||
backgroundColor: COLORS.border,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: SIZES.base / 2,
|
||||
},
|
||||
activeDot: {
|
||||
backgroundColor: COLORS.primary,
|
||||
},
|
||||
progressText: {
|
||||
...FONTS.caption,
|
||||
color: COLORS.textSecondary,
|
||||
},
|
||||
activeProgressText: {
|
||||
color: COLORS.background,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
progressLabel: {
|
||||
...FONTS.caption,
|
||||
color: COLORS.textSecondary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
activeProgressLabel: {
|
||||
color: COLORS.primary,
|
||||
},
|
||||
stepContent: {
|
||||
flex: 1,
|
||||
padding: SIZES.margin,
|
||||
},
|
||||
stepTitle: {
|
||||
...FONTS.h2,
|
||||
color: COLORS.text,
|
||||
marginBottom: SIZES.margin,
|
||||
},
|
||||
optionCard: {
|
||||
backgroundColor: COLORS.surface,
|
||||
borderRadius: SIZES.radius,
|
||||
padding: SIZES.padding,
|
||||
marginBottom: SIZES.margin,
|
||||
borderWidth: 2,
|
||||
borderColor: 'transparent',
|
||||
...SHADOWS.light,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
optionCardPressed: {
|
||||
opacity: 0.9,
|
||||
},
|
||||
selectedCard: {
|
||||
borderColor: COLORS.primary,
|
||||
},
|
||||
weekendCard: {
|
||||
borderColor: COLORS.warning,
|
||||
},
|
||||
optionTitle: {
|
||||
...FONTS.h3,
|
||||
color: COLORS.text,
|
||||
marginBottom: SIZES.base / 2,
|
||||
},
|
||||
optionDescription: {
|
||||
...FONTS.body,
|
||||
color: COLORS.textSecondary,
|
||||
marginBottom: SIZES.base,
|
||||
},
|
||||
optionFooter: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
optionPrice: {
|
||||
...FONTS.h4,
|
||||
color: COLORS.primary,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
optionDuration: {
|
||||
...FONTS.caption,
|
||||
color: COLORS.textSecondary,
|
||||
},
|
||||
optionRating: {
|
||||
...FONTS.body,
|
||||
color: COLORS.accent,
|
||||
},
|
||||
todayBadge: {
|
||||
...FONTS.caption,
|
||||
color: COLORS.success,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
weekendBadge: {
|
||||
...FONTS.caption,
|
||||
color: COLORS.warning,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
timeGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
timeSlot: {
|
||||
width: '30%',
|
||||
backgroundColor: COLORS.surface,
|
||||
borderRadius: SIZES.radius,
|
||||
padding: SIZES.padding,
|
||||
marginBottom: SIZES.margin,
|
||||
alignItems: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: 'transparent',
|
||||
...SHADOWS.light,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
timeSlotPressed: {
|
||||
opacity: 0.9,
|
||||
},
|
||||
selectedTimeSlot: {
|
||||
borderColor: COLORS.primary,
|
||||
},
|
||||
timeText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.text,
|
||||
},
|
||||
confirmationCard: {
|
||||
backgroundColor: COLORS.surface,
|
||||
borderRadius: SIZES.radius,
|
||||
padding: SIZES.padding,
|
||||
...SHADOWS.medium,
|
||||
},
|
||||
confirmationTitle: {
|
||||
...FONTS.body,
|
||||
color: COLORS.textSecondary,
|
||||
marginBottom: SIZES.base / 2,
|
||||
marginTop: SIZES.margin,
|
||||
},
|
||||
confirmationValue: {
|
||||
...FONTS.h4,
|
||||
color: COLORS.text,
|
||||
marginBottom: SIZES.margin,
|
||||
},
|
||||
confirmationPrice: {
|
||||
...FONTS.h3,
|
||||
color: COLORS.primary,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
padding: SIZES.padding,
|
||||
backgroundColor: COLORS.surface,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: COLORS.border,
|
||||
},
|
||||
backButton: {
|
||||
backgroundColor: COLORS.border,
|
||||
borderRadius: SIZES.radius,
|
||||
paddingVertical: SIZES.padding,
|
||||
paddingHorizontal: SIZES.padding * 2,
|
||||
flex: 1,
|
||||
marginRight: SIZES.base,
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
backButtonPressed: {
|
||||
opacity: 0.8,
|
||||
},
|
||||
disabledButton: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
backButtonText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.text,
|
||||
},
|
||||
nextButton: {
|
||||
backgroundColor: COLORS.primary,
|
||||
borderRadius: SIZES.radius,
|
||||
paddingVertical: SIZES.padding,
|
||||
paddingHorizontal: SIZES.padding * 2,
|
||||
flex: 2,
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
nextButtonPressed: {
|
||||
opacity: 0.9,
|
||||
},
|
||||
nextButtonText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.background,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
});
|
||||
|
||||
export default BookingScreen;
|
||||
318
src/screens/HomeScreen.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
FlatList,
|
||||
Image,
|
||||
RefreshControl,
|
||||
} from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { StackNavigationProp } from '@react-navigation/stack';
|
||||
import { RootStackParamList } from '../navigation/types';
|
||||
import { supabase, isSupabaseConfigured } from '../services/supabase';
|
||||
import { COLORS, SIZES, FONTS, SHADOWS } from '../constants/theme';
|
||||
import { Service, Barber, Promotion } from '../types';
|
||||
import { mockServices, mockBarbers, mockPromotions } from '../data/mockData';
|
||||
import ServiceCard from '../components/ServiceCard';
|
||||
import BarberCard from '../components/BarberCard';
|
||||
|
||||
const HomeScreen: React.FC = () => {
|
||||
const navigation = useNavigation<StackNavigationProp<RootStackParamList>>();
|
||||
const [featuredServices, setFeaturedServices] = useState<Service[]>([]);
|
||||
const [featuredBarbers, setFeaturedBarbers] = useState<Barber[]>([]);
|
||||
const [promotions, setPromotions] = useState<Promotion[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
if (!isSupabaseConfigured) {
|
||||
// Modo demo - usando dados de exemplo
|
||||
setFeaturedServices(mockServices.slice(0, 4));
|
||||
setFeaturedBarbers(mockBarbers.filter(b => (b.rating || 0) >= 4.7));
|
||||
setPromotions(mockPromotions);
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const [servicesRes, barbersRes, promotionsRes] = await Promise.all([
|
||||
supabase
|
||||
.from('services')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
.limit(5),
|
||||
supabase
|
||||
.from('barbers')
|
||||
.select(`
|
||||
*,
|
||||
user:users(name, photo)
|
||||
`)
|
||||
.eq('is_active', true)
|
||||
.gte('rating', 4)
|
||||
.limit(5),
|
||||
supabase
|
||||
.from('promotions')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(3),
|
||||
]);
|
||||
|
||||
if (servicesRes.data) setFeaturedServices(servicesRes.data);
|
||||
if (barbersRes.data) setFeaturedBarbers(barbersRes.data);
|
||||
if (promotionsRes.data) setPromotions(promotionsRes.data);
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar dados:', error);
|
||||
// Usando dados de exemplo devido a erro
|
||||
setFeaturedServices(mockServices.slice(0, 4));
|
||||
setFeaturedBarbers(mockBarbers.filter(b => (b.rating || 0) >= 4.7));
|
||||
setPromotions(mockPromotions);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onRefresh = () => {
|
||||
setRefreshing(true);
|
||||
loadData();
|
||||
};
|
||||
|
||||
const renderPromotion = ({ item }: { item: Promotion }) => (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.promotionCard,
|
||||
pressed && styles.promotionCardPressed
|
||||
]}
|
||||
onPress={() => navigation.navigate('Services')}
|
||||
>
|
||||
<Image source={{ uri: item.image || 'https://via.placeholder.com/300' }} style={styles.promotionImage} />
|
||||
<LinearGradient
|
||||
colors={['transparent', 'rgba(0,0,0,0.8)']}
|
||||
style={styles.promotionOverlay}
|
||||
>
|
||||
<Text style={styles.promotionTitle}>{item.title}</Text>
|
||||
<Text style={styles.promotionDescription}>{item.description}</Text>
|
||||
<Text style={styles.promotionDiscount}>{item.discount_percentage}% OFF</Text>
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
const renderService = ({ item }: { item: Service }) => (
|
||||
<ServiceCard
|
||||
service={item}
|
||||
onPress={() => navigation.navigate('ServiceDetail', { serviceId: item.id })}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderBarber = ({ item }: { item: Barber }) => (
|
||||
<BarberCard
|
||||
barber={item}
|
||||
onPress={() => navigation.navigate('BarberDetail', { barberId: item.id })}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={COLORS.primary} />
|
||||
}
|
||||
>
|
||||
{/* Hero Section */}
|
||||
<View style={styles.heroSection}>
|
||||
<LinearGradient colors={[COLORS.primary, COLORS.accent]} style={styles.heroGradient}>
|
||||
<Text style={styles.heroTitle}>Barbearia Premium</Text>
|
||||
<Text style={styles.heroSubtitle}>Experimente o luxo do cuidado masculino</Text>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.bookNowButton,
|
||||
pressed && styles.bookNowButtonPressed
|
||||
]}
|
||||
onPress={() => navigation.navigate('Booking')}
|
||||
>
|
||||
<Text style={styles.bookNowText}>Marcar Agora</Text>
|
||||
</Pressable>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
|
||||
{/* Promotions */}
|
||||
{promotions.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Ofertas Especiais</Text>
|
||||
<FlatList
|
||||
data={promotions}
|
||||
renderItem={renderPromotion}
|
||||
keyExtractor={(item) => item.id}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.promotionsList}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Featured Services */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>Serviços em Destaque</Text>
|
||||
<Pressable
|
||||
style={({ pressed }) => [pressed && styles.pressed]}
|
||||
onPress={() => navigation.navigate('Services')}
|
||||
>
|
||||
<Text style={styles.seeAllText}>Ver Todos</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<FlatList
|
||||
data={featuredServices}
|
||||
renderItem={renderService}
|
||||
keyExtractor={(item) => item.id}
|
||||
numColumns={2}
|
||||
columnWrapperStyle={styles.servicesRow}
|
||||
scrollEnabled={false}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Featured Barbers */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>Barbeiros Mais Avaliados</Text>
|
||||
<Pressable
|
||||
style={({ pressed }) => [pressed && styles.pressed]}
|
||||
onPress={() => navigation.navigate('Barbers')}
|
||||
>
|
||||
<Text style={styles.seeAllText}>Ver Todos</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<FlatList
|
||||
data={featuredBarbers}
|
||||
renderItem={renderBarber}
|
||||
keyExtractor={(item) => item.id}
|
||||
scrollEnabled={false}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: COLORS.background,
|
||||
},
|
||||
heroSection: {
|
||||
margin: SIZES.margin,
|
||||
borderRadius: SIZES.radius,
|
||||
overflow: 'hidden',
|
||||
...SHADOWS.heavy,
|
||||
},
|
||||
heroGradient: {
|
||||
padding: SIZES.padding * 2,
|
||||
alignItems: 'center',
|
||||
},
|
||||
heroTitle: {
|
||||
...FONTS.h1,
|
||||
color: COLORS.background,
|
||||
textAlign: 'center',
|
||||
marginBottom: SIZES.base,
|
||||
},
|
||||
heroSubtitle: {
|
||||
...FONTS.body,
|
||||
color: COLORS.background,
|
||||
textAlign: 'center',
|
||||
marginBottom: SIZES.margin * 2,
|
||||
opacity: 0.9,
|
||||
},
|
||||
bookNowButton: {
|
||||
backgroundColor: COLORS.background,
|
||||
paddingHorizontal: SIZES.padding * 2,
|
||||
paddingVertical: SIZES.padding,
|
||||
borderRadius: SIZES.radius,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
bookNowButtonPressed: {
|
||||
opacity: 0.8,
|
||||
},
|
||||
bookNowText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.primary,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
section: {
|
||||
margin: SIZES.margin,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: SIZES.margin,
|
||||
},
|
||||
sectionTitle: {
|
||||
...FONTS.h2,
|
||||
color: COLORS.text,
|
||||
},
|
||||
seeAllText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.primary,
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
promotionsList: {
|
||||
paddingRight: SIZES.padding,
|
||||
},
|
||||
promotionCard: {
|
||||
width: 280,
|
||||
height: 160,
|
||||
marginRight: SIZES.margin,
|
||||
borderRadius: SIZES.radius,
|
||||
overflow: 'hidden',
|
||||
...SHADOWS.medium,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
promotionCardPressed: {
|
||||
opacity: 0.9,
|
||||
},
|
||||
promotionImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
resizeMode: 'cover',
|
||||
},
|
||||
promotionOverlay: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: SIZES.padding,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
promotionTitle: {
|
||||
...FONTS.h3,
|
||||
color: COLORS.text,
|
||||
marginBottom: SIZES.base / 2,
|
||||
},
|
||||
promotionDescription: {
|
||||
...FONTS.caption,
|
||||
color: COLORS.text,
|
||||
marginBottom: SIZES.base,
|
||||
opacity: 0.9,
|
||||
},
|
||||
promotionDiscount: {
|
||||
...FONTS.h3,
|
||||
color: COLORS.accent,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
servicesRow: {
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
});
|
||||
|
||||
export default HomeScreen;
|
||||
398
src/screens/ProfileScreen.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
Alert,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { supabase, isSupabaseConfigured } from '../services/supabase';
|
||||
import { localDatabase } from '../services/localDatabase';
|
||||
import { COLORS, SIZES, FONTS, SHADOWS } from '../constants/theme';
|
||||
import { Booking } from '../types';
|
||||
import { mockBookings, mockServices, mockBarbers } from '../data/mockData';
|
||||
import BookingCard from '../components/BookingCard';
|
||||
|
||||
const ProfileScreen: React.FC = () => {
|
||||
const { user, signOut } = useAuth();
|
||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<'upcoming' | 'history'>('upcoming');
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
loadBookings();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const loadBookings = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
if (!isSupabaseConfigured) {
|
||||
// Demo mode - use local database
|
||||
await localDatabase.initialize();
|
||||
const localBookings = await localDatabase.getBookingsByCustomerId(user.id);
|
||||
|
||||
// Enrich bookings with service and barber data
|
||||
const services = await localDatabase.getServices();
|
||||
const barbers = await localDatabase.getBarbers();
|
||||
|
||||
const enriched = localBookings.map(b => ({
|
||||
...b,
|
||||
service: services?.find(s => s.id === b.service_id),
|
||||
barber: barbers?.find(ba => ba.id === b.barber_id),
|
||||
}));
|
||||
|
||||
setBookings(enriched);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('bookings')
|
||||
.select(`
|
||||
*,
|
||||
service:services(name, price, duration),
|
||||
barber:barbers(
|
||||
id,
|
||||
user:users(name, photo),
|
||||
specialty,
|
||||
rating
|
||||
)
|
||||
`)
|
||||
.eq('customer_id', user.id)
|
||||
.order('booking_date', { ascending: true })
|
||||
.order('booking_time', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
setBookings(data || []);
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar marcações:', error);
|
||||
const enriched = mockBookings.map(b => ({
|
||||
...b,
|
||||
service: b.service || mockServices.find(s => s.id === b.service_id),
|
||||
barber: b.barber || mockBarbers.find(ba => ba.id === b.barber_id),
|
||||
}));
|
||||
setBookings(enriched);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelBooking = async (bookingId: string) => {
|
||||
try {
|
||||
if (!isSupabaseConfigured) {
|
||||
await localDatabase.updateBookingStatus(bookingId, 'cancelled');
|
||||
Alert.alert('Sucesso', 'Marcação cancelada com sucesso');
|
||||
loadBookings();
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('bookings')
|
||||
.update({ status: 'cancelled' })
|
||||
.eq('id', bookingId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
Alert.alert('Sucesso', 'Marcação cancelada com sucesso');
|
||||
loadBookings();
|
||||
} catch (error: any) {
|
||||
Alert.alert('Erro', error.message || 'Falha ao cancelar marcação');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignOut = () => {
|
||||
Alert.alert(
|
||||
'Sair',
|
||||
'Tem certeza que deseja sair?',
|
||||
[
|
||||
{ text: 'Cancelar', style: 'cancel' },
|
||||
{
|
||||
text: 'Sair',
|
||||
style: 'destructive',
|
||||
onPress: signOut,
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const filteredBookings = bookings.filter(booking => {
|
||||
const bookingDateTime = new Date(`${booking.booking_date}T${booking.booking_time}`);
|
||||
const now = new Date();
|
||||
|
||||
if (activeTab === 'upcoming') {
|
||||
return bookingDateTime > now && booking.status !== 'cancelled';
|
||||
} else {
|
||||
return bookingDateTime <= now || booking.status === 'cancelled' || booking.status === 'completed';
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.errorText}>Faça login para ver o seu perfil</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
{/* Profile Header */}
|
||||
<View style={styles.profileHeader}>
|
||||
<Image
|
||||
source={{ uri: user.photo || 'https://via.placeholder.com/100' }}
|
||||
style={styles.avatar}
|
||||
/>
|
||||
<View style={styles.profileInfo}>
|
||||
<Text style={styles.name}>{user.name}</Text>
|
||||
<Text style={styles.email}>{user.email}</Text>
|
||||
<Text style={styles.role}>{user.role.charAt(0).toUpperCase() + user.role.slice(1)}</Text>
|
||||
<View style={styles.loyaltyContainer}>
|
||||
<Text style={styles.loyaltyText}>Pontos de Fidelidade</Text>
|
||||
<Text style={styles.loyaltyPoints}>{user.loyalty_points || 0}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statNumber}>
|
||||
{bookings.filter(b => b.status === 'completed').length}
|
||||
</Text>
|
||||
<Text style={styles.statLabel}>Concluídas</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statNumber}>
|
||||
{bookings.filter(b => b.status === 'pending' || b.status === 'confirmed').length}
|
||||
</Text>
|
||||
<Text style={styles.statLabel}>Marcadas</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statNumber}>€{bookings.reduce((sum, b) => sum + (b.service?.price || 0), 0)}</Text>
|
||||
<Text style={styles.statLabel}>Total Gasto</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Bookings Tabs */}
|
||||
<View style={styles.tabsContainer}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.tab,
|
||||
activeTab === 'upcoming' && styles.activeTab,
|
||||
pressed && styles.tabPressed
|
||||
]}
|
||||
onPress={() => setActiveTab('upcoming')}
|
||||
>
|
||||
<Text style={[styles.tabText, activeTab === 'upcoming' && styles.activeTabText]}>
|
||||
Futuras
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.tab,
|
||||
activeTab === 'history' && styles.activeTab,
|
||||
pressed && styles.tabPressed
|
||||
]}
|
||||
onPress={() => setActiveTab('history')}
|
||||
>
|
||||
<Text style={[styles.tabText, activeTab === 'history' && styles.activeTabText]}>
|
||||
Histórico
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Bookings List */}
|
||||
<View style={styles.bookingsContainer}>
|
||||
{loading ? (
|
||||
<Text style={styles.loadingText}>A carregar marcações...</Text>
|
||||
) : filteredBookings.length === 0 ? (
|
||||
<Text style={styles.emptyText}>
|
||||
{activeTab === 'upcoming' ? 'Nenhuma marcação futura' : 'Nenhuma marcação passada'}
|
||||
</Text>
|
||||
) : (
|
||||
filteredBookings.map((booking) => (
|
||||
<BookingCard
|
||||
key={booking.id}
|
||||
booking={booking}
|
||||
onCancel={
|
||||
booking.status === 'pending' ? () => handleCancelBooking(booking.id) : undefined
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Actions */}
|
||||
<View style={styles.actionsContainer}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.actionButton, pressed && styles.actionButtonPressed]}
|
||||
onPress={handleSignOut}
|
||||
>
|
||||
<Text style={styles.actionButtonText}>Sair</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: COLORS.background,
|
||||
},
|
||||
profileHeader: {
|
||||
flexDirection: 'row',
|
||||
padding: SIZES.padding,
|
||||
backgroundColor: COLORS.surface,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.border,
|
||||
},
|
||||
avatar: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
marginRight: SIZES.margin,
|
||||
},
|
||||
profileInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
name: {
|
||||
...FONTS.h2,
|
||||
color: COLORS.text,
|
||||
marginBottom: SIZES.base / 2,
|
||||
},
|
||||
email: {
|
||||
...FONTS.body,
|
||||
color: COLORS.textSecondary,
|
||||
marginBottom: SIZES.base / 2,
|
||||
},
|
||||
role: {
|
||||
...FONTS.caption,
|
||||
color: COLORS.primary,
|
||||
backgroundColor: COLORS.background,
|
||||
paddingHorizontal: SIZES.base,
|
||||
paddingVertical: SIZES.base / 2,
|
||||
borderRadius: SIZES.base,
|
||||
alignSelf: 'flex-start',
|
||||
marginBottom: SIZES.base,
|
||||
},
|
||||
loyaltyContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: COLORS.primary,
|
||||
paddingHorizontal: SIZES.base,
|
||||
paddingVertical: SIZES.base / 2,
|
||||
borderRadius: SIZES.base,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
loyaltyText: {
|
||||
...FONTS.caption,
|
||||
color: COLORS.background,
|
||||
marginRight: SIZES.base,
|
||||
},
|
||||
loyaltyPoints: {
|
||||
...FONTS.h3,
|
||||
color: COLORS.background,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
statsContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
padding: SIZES.margin,
|
||||
backgroundColor: COLORS.surface,
|
||||
margin: SIZES.margin,
|
||||
borderRadius: SIZES.radius,
|
||||
...SHADOWS.medium,
|
||||
},
|
||||
statCard: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
statNumber: {
|
||||
...FONTS.h2,
|
||||
color: COLORS.primary,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
statLabel: {
|
||||
...FONTS.caption,
|
||||
color: COLORS.textSecondary,
|
||||
},
|
||||
tabsContainer: {
|
||||
flexDirection: 'row',
|
||||
marginHorizontal: SIZES.margin,
|
||||
marginBottom: SIZES.margin,
|
||||
backgroundColor: COLORS.surface,
|
||||
borderRadius: SIZES.radius,
|
||||
padding: SIZES.base,
|
||||
...SHADOWS.light,
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
paddingVertical: SIZES.base,
|
||||
alignItems: 'center',
|
||||
borderRadius: SIZES.base,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
tabPressed: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
activeTab: {
|
||||
backgroundColor: COLORS.primary,
|
||||
},
|
||||
tabText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.textSecondary,
|
||||
},
|
||||
activeTabText: {
|
||||
color: COLORS.background,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
bookingsContainer: {
|
||||
marginHorizontal: SIZES.margin,
|
||||
},
|
||||
loadingText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.textSecondary,
|
||||
textAlign: 'center',
|
||||
paddingVertical: SIZES.padding * 2,
|
||||
},
|
||||
emptyText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.textSecondary,
|
||||
textAlign: 'center',
|
||||
paddingVertical: SIZES.padding * 2,
|
||||
},
|
||||
actionsContainer: {
|
||||
padding: SIZES.margin,
|
||||
marginTop: SIZES.margin,
|
||||
},
|
||||
actionButton: {
|
||||
backgroundColor: COLORS.error,
|
||||
borderRadius: SIZES.radius,
|
||||
padding: SIZES.padding,
|
||||
alignItems: 'center',
|
||||
...SHADOWS.medium,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
actionButtonPressed: {
|
||||
opacity: 0.8,
|
||||
},
|
||||
actionButtonText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.text,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
errorText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.error,
|
||||
textAlign: 'center',
|
||||
paddingVertical: SIZES.padding * 2,
|
||||
},
|
||||
});
|
||||
|
||||
export default ProfileScreen;
|
||||
269
src/screens/ServiceDetailScreen.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
Image,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { useRoute, useNavigation } from '@react-navigation/native';
|
||||
import { StackNavigationProp } from '@react-navigation/stack';
|
||||
import { RootStackParamList } from '../navigation/types';
|
||||
import { supabase, isSupabaseConfigured } from '../services/supabase';
|
||||
import { COLORS, SIZES, FONTS, SHADOWS } from '../constants/theme';
|
||||
import { Service, Review } from '../types';
|
||||
import ReviewCard from '../components/ReviewCard';
|
||||
|
||||
const ServiceDetailScreen: React.FC = () => {
|
||||
const route = useRoute();
|
||||
const navigation = useNavigation<StackNavigationProp<RootStackParamList>>();
|
||||
const { serviceId } = route.params as { serviceId: string };
|
||||
|
||||
const [service, setService] = useState<Service | null>(null);
|
||||
const [reviews, setReviews] = useState<Review[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadServiceDetails();
|
||||
}, [serviceId]);
|
||||
|
||||
const loadServiceDetails = async () => {
|
||||
try {
|
||||
if (!isSupabaseConfigured) {
|
||||
// Demo mode - use mock data
|
||||
const { mockServices } = await import('../data/mockData');
|
||||
const service = mockServices.find(s => s.id === serviceId);
|
||||
if (service) {
|
||||
setService(service);
|
||||
// Load service-specific reviews
|
||||
const { mockReviews } = await import('../data/mockData');
|
||||
const serviceReviews = mockReviews.filter(r => r.service_id === serviceId);
|
||||
setReviews(serviceReviews);
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const [serviceRes, reviewsRes] = await Promise.all([
|
||||
supabase
|
||||
.from('services')
|
||||
.select('*')
|
||||
.eq('id', serviceId)
|
||||
.single(),
|
||||
supabase
|
||||
.from('reviews')
|
||||
.select(`
|
||||
*,
|
||||
user:users(name, photo)
|
||||
`)
|
||||
.eq('service_id', serviceId)
|
||||
.order('created_at', { ascending: false })
|
||||
]);
|
||||
|
||||
if (serviceRes.data) setService(serviceRes.data);
|
||||
if (reviewsRes.data) setReviews(reviewsRes.data);
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar detalhes do serviço:', error);
|
||||
Alert.alert('Erro', 'Falha ao carregar detalhes do serviço');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBookNow = () => {
|
||||
Alert.alert(
|
||||
'📋 Marcar Serviço',
|
||||
`Vou marcar: ${service?.name}`,
|
||||
[
|
||||
{
|
||||
text: 'Cancelar',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Confirmar',
|
||||
onPress: () => {
|
||||
Alert.alert(
|
||||
'✅ Serviço Selecionado',
|
||||
`${service?.name} foi selecionado para marcação.\n\nContinue para escolher barbeiro, data e horário.`,
|
||||
[
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => navigation.navigate('Booking'),
|
||||
},
|
||||
]
|
||||
);
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.loadingText}>A carregar...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!service) {
|
||||
return (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>Serviço não encontrado</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
{/* Service Image */}
|
||||
<Image
|
||||
source={{ uri: service.image || 'https://via.placeholder.com/400' }}
|
||||
style={styles.serviceImage}
|
||||
/>
|
||||
|
||||
{/* Service Info */}
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title}>{service.name}</Text>
|
||||
<Text style={styles.description}>{service.description}</Text>
|
||||
|
||||
<View style={styles.detailsContainer}>
|
||||
<View style={styles.detailItem}>
|
||||
<Text style={styles.detailLabel}>Duração</Text>
|
||||
<Text style={styles.detailValue}>{service.duration} minutos</Text>
|
||||
</View>
|
||||
<View style={styles.detailItem}>
|
||||
<Text style={styles.detailLabel}>Preço</Text>
|
||||
<Text style={styles.price}>€{service.price}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.bookButton, pressed && styles.bookButtonPressed]}
|
||||
onPress={handleBookNow}
|
||||
>
|
||||
<Text style={styles.bookButtonText}>Marcar Este Serviço</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* Reviews Section */}
|
||||
<View style={styles.reviewsSection}>
|
||||
<Text style={styles.sectionTitle}>Avaliações de Clientes</Text>
|
||||
{reviews.length === 0 ? (
|
||||
<Text style={styles.noReviewsText}>Sem avaliações</Text>
|
||||
) : (
|
||||
reviews.map((review) => (
|
||||
<ReviewCard key={review.id} review={review} />
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: COLORS.background,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.textSecondary,
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
errorText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.error,
|
||||
},
|
||||
serviceImage: {
|
||||
width: '100%',
|
||||
height: 250,
|
||||
resizeMode: 'cover',
|
||||
},
|
||||
content: {
|
||||
padding: SIZES.padding,
|
||||
},
|
||||
title: {
|
||||
...FONTS.h1,
|
||||
color: COLORS.text,
|
||||
marginBottom: SIZES.margin,
|
||||
},
|
||||
description: {
|
||||
...FONTS.body,
|
||||
color: COLORS.textSecondary,
|
||||
lineHeight: 24,
|
||||
marginBottom: SIZES.margin * 2,
|
||||
},
|
||||
detailsContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: SIZES.margin * 2,
|
||||
},
|
||||
detailItem: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
backgroundColor: COLORS.surface,
|
||||
padding: SIZES.padding,
|
||||
borderRadius: SIZES.radius,
|
||||
marginHorizontal: SIZES.base,
|
||||
...SHADOWS.light,
|
||||
},
|
||||
detailLabel: {
|
||||
...FONTS.caption,
|
||||
color: COLORS.textSecondary,
|
||||
marginBottom: SIZES.base / 2,
|
||||
},
|
||||
detailValue: {
|
||||
...FONTS.h3,
|
||||
color: COLORS.text,
|
||||
},
|
||||
price: {
|
||||
...FONTS.h2,
|
||||
color: COLORS.primary,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
bookButton: {
|
||||
backgroundColor: COLORS.primary,
|
||||
borderRadius: SIZES.radius,
|
||||
padding: SIZES.padding,
|
||||
alignItems: 'center',
|
||||
marginBottom: SIZES.margin * 2,
|
||||
...SHADOWS.medium,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
bookButtonPressed: {
|
||||
opacity: 0.9,
|
||||
},
|
||||
bookButtonText: {
|
||||
...FONTS.h3,
|
||||
color: COLORS.background,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
reviewsSection: {
|
||||
marginTop: SIZES.margin,
|
||||
},
|
||||
sectionTitle: {
|
||||
...FONTS.h2,
|
||||
color: COLORS.text,
|
||||
marginBottom: SIZES.margin,
|
||||
},
|
||||
noReviewsText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.textSecondary,
|
||||
textAlign: 'center',
|
||||
paddingVertical: SIZES.padding,
|
||||
},
|
||||
});
|
||||
|
||||
export default ServiceDetailScreen;
|
||||
155
src/screens/ServicesScreen.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
RefreshControl,
|
||||
TextInput,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { StackNavigationProp } from '@react-navigation/stack';
|
||||
import { RootStackParamList } from '../navigation/types';
|
||||
import { supabase, isSupabaseConfigured } from '../services/supabase';
|
||||
import { mockServices } from '../data/mockData';
|
||||
import { COLORS, SIZES, FONTS } from '../constants/theme';
|
||||
import { Service } from '../types';
|
||||
import ServiceCard from '../components/ServiceCard';
|
||||
|
||||
const ServicesScreen: React.FC = () => {
|
||||
const navigation = useNavigation<StackNavigationProp<RootStackParamList>>();
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [filteredServices, setFilteredServices] = useState<Service[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadServices();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchQuery) {
|
||||
const filtered = services.filter(service =>
|
||||
service.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
service.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
setFilteredServices(filtered);
|
||||
} else {
|
||||
setFilteredServices(services);
|
||||
}
|
||||
}, [searchQuery, services]);
|
||||
|
||||
const loadServices = async () => {
|
||||
try {
|
||||
if (!isSupabaseConfigured) {
|
||||
setServices(mockServices);
|
||||
setFilteredServices(mockServices);
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('services')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
.order('name');
|
||||
|
||||
if (error) throw error;
|
||||
setServices(data || []);
|
||||
setFilteredServices(data || []);
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar serviços:', error);
|
||||
setServices(mockServices);
|
||||
setFilteredServices(mockServices);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onRefresh = () => {
|
||||
setRefreshing(true);
|
||||
loadServices();
|
||||
};
|
||||
|
||||
const renderService = ({ item }: { item: Service }) => (
|
||||
<ServiceCard
|
||||
service={item}
|
||||
onPress={() => navigation.navigate('ServiceDetail', { serviceId: item.id })}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Os Nossos Serviços</Text>
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder="Pesquisar serviços..."
|
||||
placeholderTextColor={COLORS.textSecondary}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={filteredServices}
|
||||
renderItem={renderService}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.list}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={COLORS.primary} />
|
||||
}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>Nenhum serviço encontrado</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: COLORS.background,
|
||||
},
|
||||
header: {
|
||||
padding: SIZES.padding,
|
||||
backgroundColor: COLORS.surface,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.border,
|
||||
},
|
||||
title: {
|
||||
...FONTS.h2,
|
||||
color: COLORS.text,
|
||||
marginBottom: SIZES.margin,
|
||||
},
|
||||
searchInput: {
|
||||
backgroundColor: COLORS.background,
|
||||
borderRadius: SIZES.radius,
|
||||
padding: SIZES.padding,
|
||||
color: COLORS.text,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.border,
|
||||
},
|
||||
list: {
|
||||
padding: SIZES.margin,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: SIZES.padding * 4,
|
||||
},
|
||||
emptyText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.textSecondary,
|
||||
},
|
||||
});
|
||||
|
||||
export default ServicesScreen;
|
||||
456
src/screens/admin/AdminDashboardScreen.tsx
Normal file
@@ -0,0 +1,456 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
Alert,
|
||||
RefreshControl,
|
||||
} from 'react-native';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { supabase, isSupabaseConfigured } from '../../services/supabase';
|
||||
import LocalDataService from '../../services/localDataService';
|
||||
import { COLORS, SIZES, FONTS, SHADOWS } from '../../constants/theme';
|
||||
import { Booking, Barber, Service, User } from '../../types';
|
||||
|
||||
const AdminDashboardScreen: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const [stats, setStats] = useState({
|
||||
totalBookings: 0,
|
||||
totalRevenue: 0,
|
||||
activeBarbers: 0,
|
||||
totalCustomers: 0,
|
||||
});
|
||||
const [recentBookings, setRecentBookings] = useState<Booking[]>([]);
|
||||
const [barbers, setBarbers] = useState<Barber[]>([]);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.role === 'admin') {
|
||||
loadDashboardData();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
if (isSupabaseConfigured) {
|
||||
// Use Supabase if configured
|
||||
const [bookingsRes, barbersRes, servicesRes, customersRes] = await Promise.all([
|
||||
supabase
|
||||
.from('bookings')
|
||||
.select(`
|
||||
*,
|
||||
service:services(name, price),
|
||||
customer:users(name),
|
||||
barber:barbers(
|
||||
user:users(name)
|
||||
)
|
||||
`)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10),
|
||||
supabase
|
||||
.from('barbers')
|
||||
.select(`
|
||||
*,
|
||||
user:users(name)
|
||||
`)
|
||||
.eq('is_active', true),
|
||||
supabase
|
||||
.from('services')
|
||||
.select('*')
|
||||
.eq('is_active', true),
|
||||
supabase
|
||||
.from('users')
|
||||
.select('*')
|
||||
.eq('role', 'customer')
|
||||
]);
|
||||
|
||||
if (bookingsRes.data) {
|
||||
const bookings = bookingsRes.data as Booking[];
|
||||
setRecentBookings(bookings.slice(0, 5));
|
||||
|
||||
// Calculate stats
|
||||
const completedBookings = bookings.filter(b => b.status === 'completed');
|
||||
const totalRevenue = completedBookings.reduce((sum, b) => {
|
||||
return sum + (b.service as any)?.price || 0;
|
||||
}, 0);
|
||||
|
||||
setStats({
|
||||
totalBookings: bookings.length,
|
||||
totalRevenue,
|
||||
activeBarbers: barbersRes.data?.length || 0,
|
||||
totalCustomers: customersRes.data?.length || 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (barbersRes.data) setBarbers(barbersRes.data);
|
||||
if (servicesRes.data) setServices(servicesRes.data);
|
||||
} else {
|
||||
// Use local database
|
||||
const [bookings, statsData, barbersData, servicesData] = await Promise.all([
|
||||
LocalDataService.getBookings(),
|
||||
LocalDataService.getStats(),
|
||||
LocalDataService.getBarbers(),
|
||||
LocalDataService.getServices(),
|
||||
]);
|
||||
|
||||
if (bookings) {
|
||||
setRecentBookings(bookings.slice(0, 5));
|
||||
}
|
||||
|
||||
if (statsData) {
|
||||
setStats(statsData);
|
||||
}
|
||||
|
||||
if (barbersData) {
|
||||
setBarbers(barbersData);
|
||||
}
|
||||
|
||||
if (servicesData) {
|
||||
setServices(servicesData);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar dados do painel:', error);
|
||||
Alert.alert('Erro', 'Falha ao carregar dados do painel');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onRefresh = () => {
|
||||
setRefreshing(true);
|
||||
loadDashboardData();
|
||||
};
|
||||
|
||||
const handleUpdateBookingStatus = async (bookingId: string, status: string) => {
|
||||
try {
|
||||
if (isSupabaseConfigured) {
|
||||
const { error } = await supabase
|
||||
.from('bookings')
|
||||
.update({ status })
|
||||
.eq('id', bookingId);
|
||||
|
||||
if (error) throw error;
|
||||
} else {
|
||||
const result = await LocalDataService.updateBookingStatus(bookingId, status as Booking['status']);
|
||||
if (!result) throw new Error('Falha ao actualizar marcação');
|
||||
}
|
||||
|
||||
Alert.alert('Sucesso', `Marcação ${status} com sucesso`);
|
||||
loadDashboardData();
|
||||
} catch (error: any) {
|
||||
Alert.alert('Erro', error.message || 'Falha ao actualizar marcação');
|
||||
}
|
||||
};
|
||||
|
||||
const StatCard = ({ title, value, subtitle }: { title: string; value: string | number; subtitle?: string }) => (
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statTitle}>{title}</Text>
|
||||
<Text style={styles.statValue}>{value}</Text>
|
||||
{subtitle && <Text style={styles.statSubtitle}>{subtitle}</Text>}
|
||||
</View>
|
||||
);
|
||||
|
||||
const BookingItem = ({ booking }: { booking: Booking }) => (
|
||||
<View style={styles.bookingItem}>
|
||||
<View style={styles.bookingHeader}>
|
||||
<Text style={styles.bookingService}>{booking.service?.name}</Text>
|
||||
<View style={[styles.statusBadge, {
|
||||
backgroundColor: booking.status === 'completed' ? COLORS.success :
|
||||
booking.status === 'confirmed' ? COLORS.primary :
|
||||
booking.status === 'pending' ? COLORS.warning :
|
||||
booking.status === 'cancelled' ? COLORS.error : COLORS.textSecondary
|
||||
}]}>
|
||||
<Text style={styles.statusText}>{booking.status}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.bookingCustomer}>Cliente: {booking.customer?.name}</Text>
|
||||
<Text style={styles.bookingBarber}>Barbeiro: {booking.barber?.user?.name}</Text>
|
||||
<Text style={styles.bookingDate}>
|
||||
{new Date(booking.booking_date).toLocaleDateString('pt-PT')} às {booking.booking_time}
|
||||
</Text>
|
||||
<Text style={styles.bookingPrice}>€{booking.service?.price}</Text>
|
||||
|
||||
{booking.status === 'pending' && (
|
||||
<View style={styles.bookingActions}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.actionButton,
|
||||
styles.confirmButton,
|
||||
pressed && styles.actionButtonPressed
|
||||
]}
|
||||
onPress={() => handleUpdateBookingStatus(booking.id, 'confirmed')}
|
||||
>
|
||||
<Text style={styles.actionButtonText}>Confirmar</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.actionButton,
|
||||
styles.cancelButton,
|
||||
pressed && styles.actionButtonPressed
|
||||
]}
|
||||
onPress={() => handleUpdateBookingStatus(booking.id, 'cancelled')}
|
||||
>
|
||||
<Text style={styles.actionButtonText}>Cancelar</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
if (user?.role !== 'admin') {
|
||||
return (
|
||||
<View style={styles.accessDenied}>
|
||||
<Text style={styles.accessDeniedText}>Acesso Negado</Text>
|
||||
<Text style={styles.accessDeniedSubtext}>Requer acesso de administrador</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.loadingText}>A carregar painel...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={COLORS.primary} />
|
||||
}
|
||||
>
|
||||
<Text style={styles.title}>Painel de Administração</Text>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<View style={styles.statsContainer}>
|
||||
<StatCard title="Total de Marcações" value={stats.totalBookings} />
|
||||
<StatCard title="Receita Total" value={`€${stats.totalRevenue}`} />
|
||||
<StatCard title="Barbeiros Activos" value={stats.activeBarbers} />
|
||||
<StatCard title="Total de Clientes" value={stats.totalCustomers} />
|
||||
</View>
|
||||
|
||||
{/* Recent Bookings */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Marcações Recentes</Text>
|
||||
{recentBookings.length === 0 ? (
|
||||
<Text style={styles.emptyText}>Sem marcações recentes</Text>
|
||||
) : (
|
||||
recentBookings.map((booking) => (
|
||||
<BookingItem key={booking.id} booking={booking} />
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Acções Rápidas</Text>
|
||||
<View style={styles.actionsGrid}>
|
||||
<Pressable style={({ pressed }) => [styles.quickActionButton, pressed && styles.quickActionButtonPressed]}>
|
||||
<Text style={styles.quickActionText}>Gerir Serviços</Text>
|
||||
</Pressable>
|
||||
<Pressable style={({ pressed }) => [styles.quickActionButton, pressed && styles.quickActionButtonPressed]}>
|
||||
<Text style={styles.quickActionText}>Gerir Barbeiros</Text>
|
||||
</Pressable>
|
||||
<Pressable style={({ pressed }) => [styles.quickActionButton, pressed && styles.quickActionButtonPressed]}>
|
||||
<Text style={styles.quickActionText}>Ver Relatórios</Text>
|
||||
</Pressable>
|
||||
<Pressable style={({ pressed }) => [styles.quickActionButton, pressed && styles.quickActionButtonPressed]}>
|
||||
<Text style={styles.quickActionText}>Definições</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: COLORS.background,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.textSecondary,
|
||||
},
|
||||
accessDenied: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
accessDeniedText: {
|
||||
...FONTS.h2,
|
||||
color: COLORS.error,
|
||||
marginBottom: SIZES.base,
|
||||
},
|
||||
accessDeniedSubtext: {
|
||||
...FONTS.body,
|
||||
color: COLORS.textSecondary,
|
||||
},
|
||||
title: {
|
||||
...FONTS.h1,
|
||||
color: COLORS.text,
|
||||
textAlign: 'center',
|
||||
padding: SIZES.padding,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.border,
|
||||
},
|
||||
statsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
padding: SIZES.margin,
|
||||
},
|
||||
statCard: {
|
||||
width: '48%',
|
||||
backgroundColor: COLORS.surface,
|
||||
borderRadius: SIZES.radius,
|
||||
padding: SIZES.padding,
|
||||
marginBottom: SIZES.margin,
|
||||
alignItems: 'center',
|
||||
...SHADOWS.medium,
|
||||
},
|
||||
statTitle: {
|
||||
...FONTS.caption,
|
||||
color: COLORS.textSecondary,
|
||||
marginBottom: SIZES.base / 2,
|
||||
},
|
||||
statValue: {
|
||||
...FONTS.h2,
|
||||
color: COLORS.primary,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
statSubtitle: {
|
||||
...FONTS.caption,
|
||||
color: COLORS.textSecondary,
|
||||
marginTop: SIZES.base / 2,
|
||||
},
|
||||
section: {
|
||||
margin: SIZES.margin,
|
||||
},
|
||||
sectionTitle: {
|
||||
...FONTS.h2,
|
||||
color: COLORS.text,
|
||||
marginBottom: SIZES.margin,
|
||||
},
|
||||
emptyText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.textSecondary,
|
||||
textAlign: 'center',
|
||||
paddingVertical: SIZES.padding,
|
||||
},
|
||||
bookingItem: {
|
||||
backgroundColor: COLORS.surface,
|
||||
borderRadius: SIZES.radius,
|
||||
padding: SIZES.padding,
|
||||
marginBottom: SIZES.margin,
|
||||
...SHADOWS.light,
|
||||
},
|
||||
bookingHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: SIZES.margin,
|
||||
},
|
||||
bookingService: {
|
||||
...FONTS.h3,
|
||||
color: COLORS.text,
|
||||
flex: 1,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: SIZES.base,
|
||||
paddingVertical: SIZES.base / 2,
|
||||
borderRadius: SIZES.base / 2,
|
||||
},
|
||||
statusText: {
|
||||
...FONTS.caption,
|
||||
color: COLORS.background,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
bookingCustomer: {
|
||||
...FONTS.body,
|
||||
color: COLORS.textSecondary,
|
||||
marginBottom: SIZES.base / 2,
|
||||
},
|
||||
bookingBarber: {
|
||||
...FONTS.body,
|
||||
color: COLORS.textSecondary,
|
||||
marginBottom: SIZES.base / 2,
|
||||
},
|
||||
bookingDate: {
|
||||
...FONTS.caption,
|
||||
color: COLORS.textSecondary,
|
||||
marginBottom: SIZES.base / 2,
|
||||
},
|
||||
bookingPrice: {
|
||||
...FONTS.h3,
|
||||
color: COLORS.primary,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: SIZES.margin,
|
||||
},
|
||||
bookingActions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
actionButton: {
|
||||
borderRadius: SIZES.base,
|
||||
paddingVertical: SIZES.base,
|
||||
paddingHorizontal: SIZES.padding,
|
||||
flex: 1,
|
||||
marginHorizontal: SIZES.base / 2,
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
actionButtonPressed: {
|
||||
opacity: 0.8,
|
||||
},
|
||||
confirmButton: {
|
||||
backgroundColor: COLORS.success,
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: COLORS.error,
|
||||
},
|
||||
actionButtonText: {
|
||||
...FONTS.caption,
|
||||
color: COLORS.background,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
actionsGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
quickActionButton: {
|
||||
width: '48%',
|
||||
backgroundColor: COLORS.surface,
|
||||
borderRadius: SIZES.radius,
|
||||
padding: SIZES.padding,
|
||||
marginBottom: SIZES.margin,
|
||||
alignItems: 'center',
|
||||
...SHADOWS.light,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
quickActionButtonPressed: {
|
||||
opacity: 0.9,
|
||||
},
|
||||
quickActionText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.text,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default AdminDashboardScreen;
|
||||
200
src/screens/auth/LoginScreen.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { isSupabaseConfigured } from '../../services/supabase';
|
||||
import { COLORS, SIZES, FONTS } from '../../constants/theme';
|
||||
|
||||
const LoginScreen: React.FC<{ navigation: any }> = ({ navigation }) => {
|
||||
const isDemo = !isSupabaseConfigured;
|
||||
const [email, setEmail] = useState(isDemo ? 'demo@appbarber.com' : '');
|
||||
const [password, setPassword] = useState(isDemo ? 'demo123' : '');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { signIn } = useAuth();
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!email || !password) {
|
||||
Alert.alert('Erro', 'Por favor preencha todos os campos');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const { error } = await signIn(email, password);
|
||||
setLoading(false);
|
||||
|
||||
if (error) {
|
||||
Alert.alert('Erro de Login', error.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Bem-vindo de Volta</Text>
|
||||
<Text style={styles.subtitle}>Entre na sua conta</Text>
|
||||
{isDemo && (
|
||||
<View style={styles.demoBadge}>
|
||||
<Text style={styles.demoBadgeText}>MODO DEMO - Qualquer credencial funciona</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>E-mail</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder="Digite o seu e-mail"
|
||||
placeholderTextColor={COLORS.textSecondary}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Palavra-passe</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder="Digite a sua palavra-passe"
|
||||
placeholderTextColor={COLORS.textSecondary}
|
||||
secureTextEntry
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.button,
|
||||
loading && styles.buttonDisabled,
|
||||
pressed && styles.buttonPressed
|
||||
]}
|
||||
onPress={handleLogin}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{loading ? 'A entrar...' : 'Entrar'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.linkButton,
|
||||
pressed && styles.linkButtonPressed
|
||||
]}
|
||||
onPress={() => navigation.navigate('Register')}
|
||||
>
|
||||
<Text style={styles.linkText}>
|
||||
Não tem conta? Registe-se
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: COLORS.background,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
padding: SIZES.padding * 2,
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginBottom: SIZES.margin * 3,
|
||||
},
|
||||
title: {
|
||||
...FONTS.h1,
|
||||
color: COLORS.text,
|
||||
marginBottom: SIZES.base,
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
...FONTS.body,
|
||||
color: COLORS.textSecondary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
form: {
|
||||
width: '100%',
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: SIZES.margin,
|
||||
},
|
||||
label: {
|
||||
...FONTS.body,
|
||||
color: COLORS.text,
|
||||
marginBottom: SIZES.base / 2,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: COLORS.surface,
|
||||
borderRadius: SIZES.radius,
|
||||
padding: SIZES.padding,
|
||||
color: COLORS.text,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.border,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: COLORS.primary,
|
||||
borderRadius: SIZES.radius,
|
||||
padding: SIZES.padding,
|
||||
alignItems: 'center',
|
||||
marginBottom: SIZES.margin,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: COLORS.textSecondary,
|
||||
},
|
||||
buttonPressed: {
|
||||
opacity: 0.8,
|
||||
},
|
||||
buttonText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.background,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
linkButton: {
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
linkButtonPressed: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
linkText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.primary,
|
||||
},
|
||||
demoBadge: {
|
||||
backgroundColor: COLORS.warning,
|
||||
paddingHorizontal: SIZES.padding,
|
||||
paddingVertical: SIZES.base,
|
||||
borderRadius: SIZES.radius,
|
||||
marginTop: SIZES.margin,
|
||||
},
|
||||
demoBadgeText: {
|
||||
...FONTS.caption,
|
||||
color: COLORS.background,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
});
|
||||
|
||||
export default LoginScreen;
|
||||
290
src/screens/auth/RegisterScreen.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { COLORS, SIZES, FONTS } from '../../constants/theme';
|
||||
|
||||
const RegisterScreen: React.FC<{ navigation: any }> = ({ navigation }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [role, setRole] = useState<'customer' | 'barber'>('customer');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { signUp } = useAuth();
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!name || !email || !phone || !password || !confirmPassword) {
|
||||
Alert.alert('Erro', 'Por favor preencha todos os campos');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
Alert.alert('Erro', 'As palavras-passe não coincidem');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
Alert.alert('Erro', 'A palavra-passe deve ter pelo menos 6 caracteres');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const { error } = await signUp(email, password, name, phone, role);
|
||||
setLoading(false);
|
||||
|
||||
if (error) {
|
||||
Alert.alert('Erro de Registo', error.message);
|
||||
} else {
|
||||
Alert.alert('Sucesso', 'Conta criada com sucesso! Por favor verifique o seu e-mail para confirmar a sua conta.');
|
||||
navigation.navigate('Login');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Criar Conta</Text>
|
||||
<Text style={styles.subtitle}>Junte-se à nossa barbearia premium</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Nome Completo</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Digite o seu nome completo"
|
||||
placeholderTextColor={COLORS.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>E-mail</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder="Digite o seu e-mail"
|
||||
placeholderTextColor={COLORS.textSecondary}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Telefone</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={phone}
|
||||
onChangeText={setPhone}
|
||||
placeholder="Digite o seu telefone"
|
||||
placeholderTextColor={COLORS.textSecondary}
|
||||
keyboardType="phone-pad"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Tipo de Conta</Text>
|
||||
<View style={styles.roleContainer}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.roleButton,
|
||||
role === 'customer' && styles.roleButtonActive,
|
||||
pressed && styles.roleButtonPressed
|
||||
]}
|
||||
onPress={() => setRole('customer')}
|
||||
>
|
||||
<Text style={[styles.roleButtonText, role === 'customer' && styles.roleButtonTextActive]}>
|
||||
Cliente
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.roleButton,
|
||||
role === 'barber' && styles.roleButtonActive,
|
||||
pressed && styles.roleButtonPressed
|
||||
]}
|
||||
onPress={() => setRole('barber')}
|
||||
>
|
||||
<Text style={[styles.roleButtonText, role === 'barber' && styles.roleButtonTextActive]}>
|
||||
Barbeiro
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Palavra-passe</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder="Digite a sua palavra-passe"
|
||||
placeholderTextColor={COLORS.textSecondary}
|
||||
secureTextEntry
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Confirmar Palavra-passe</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
placeholder="Confirme a sua palavra-passe"
|
||||
placeholderTextColor={COLORS.textSecondary}
|
||||
secureTextEntry
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.button,
|
||||
loading && styles.buttonDisabled,
|
||||
pressed && styles.buttonPressed
|
||||
]}
|
||||
onPress={handleRegister}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{loading ? 'A Criar Conta...' : 'Criar Conta'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.linkButton,
|
||||
pressed && styles.linkButtonPressed
|
||||
]}
|
||||
onPress={() => navigation.navigate('Login')}
|
||||
>
|
||||
<Text style={styles.linkText}>
|
||||
Já tem conta? Iniciar Sessão
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: COLORS.background,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
padding: SIZES.padding * 2,
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginBottom: SIZES.margin * 3,
|
||||
},
|
||||
title: {
|
||||
...FONTS.h1,
|
||||
color: COLORS.text,
|
||||
marginBottom: SIZES.base,
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
...FONTS.body,
|
||||
color: COLORS.textSecondary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
form: {
|
||||
width: '100%',
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: SIZES.margin,
|
||||
},
|
||||
label: {
|
||||
...FONTS.body,
|
||||
color: COLORS.text,
|
||||
marginBottom: SIZES.base / 2,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: COLORS.surface,
|
||||
borderRadius: SIZES.radius,
|
||||
padding: SIZES.padding,
|
||||
color: COLORS.text,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.border,
|
||||
},
|
||||
roleContainer: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: COLORS.surface,
|
||||
borderRadius: SIZES.radius,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.border,
|
||||
},
|
||||
roleButton: {
|
||||
flex: 1,
|
||||
padding: SIZES.padding,
|
||||
alignItems: 'center',
|
||||
borderRadius: SIZES.radius,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
roleButtonPressed: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
roleButtonActive: {
|
||||
backgroundColor: COLORS.primary,
|
||||
},
|
||||
roleButtonText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.textSecondary,
|
||||
},
|
||||
roleButtonTextActive: {
|
||||
color: COLORS.background,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: COLORS.primary,
|
||||
borderRadius: SIZES.radius,
|
||||
padding: SIZES.padding,
|
||||
alignItems: 'center',
|
||||
marginBottom: SIZES.margin,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: COLORS.textSecondary,
|
||||
},
|
||||
buttonPressed: {
|
||||
opacity: 0.8,
|
||||
},
|
||||
buttonText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.background,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
linkButton: {
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
linkButtonPressed: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
linkText: {
|
||||
...FONTS.body,
|
||||
color: COLORS.primary,
|
||||
},
|
||||
});
|
||||
|
||||
export default RegisterScreen;
|
||||
160
src/services/localDataService.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { localDatabase } from './localDatabase';
|
||||
import { Service, Barber, Booking, Review, Promotion } from '../types';
|
||||
|
||||
// Service to replace Supabase calls with local database calls
|
||||
export class LocalDataService {
|
||||
// Services
|
||||
static async getServices() {
|
||||
if (localDatabase) {
|
||||
return await localDatabase.getServices();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static async getServiceById(id: string) {
|
||||
if (localDatabase) {
|
||||
return await localDatabase.getServiceById(id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Barbers
|
||||
static async getBarbers() {
|
||||
if (localDatabase) {
|
||||
const barbers = await localDatabase.getBarbers();
|
||||
// Add user data to each barber
|
||||
if (barbers) {
|
||||
const users = await localDatabase.getUsers();
|
||||
return barbers.map(barber => ({
|
||||
...barber,
|
||||
user: users?.find(u => u.id === barber.user_id),
|
||||
}));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static async getBarberById(id: string) {
|
||||
if (localDatabase) {
|
||||
const barber = await localDatabase.getBarberById(id);
|
||||
if (barber) {
|
||||
const user = await localDatabase.getUserById(barber.user_id);
|
||||
return { ...barber, user };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Bookings
|
||||
static async getBookings() {
|
||||
if (localDatabase) {
|
||||
return await localDatabase.getAllBookingsWithDetails();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static async getBookingsByCustomerId(customerId: string) {
|
||||
if (localDatabase) {
|
||||
const bookings = await localDatabase.getBookingsByCustomerId(customerId);
|
||||
// Add service, barber, and customer details
|
||||
const services = await localDatabase.getServices();
|
||||
const barbers = await localDatabase.getBarbers();
|
||||
const users = await localDatabase.getUsers();
|
||||
|
||||
return bookings.map(booking => ({
|
||||
...booking,
|
||||
service: services?.find(s => s.id === booking.service_id),
|
||||
barber: barbers?.find(b => b.id === booking.barber_id),
|
||||
customer: users?.find(u => u.id === booking.customer_id),
|
||||
}));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static async getBookingsByBarberId(barberId: string) {
|
||||
if (localDatabase) {
|
||||
const bookings = await localDatabase.getBookingsByBarberId(barberId);
|
||||
// Add service, barber, and customer details
|
||||
const services = await localDatabase.getServices();
|
||||
const barbers = await localDatabase.getBarbers();
|
||||
const users = await localDatabase.getUsers();
|
||||
|
||||
return bookings.map(booking => ({
|
||||
...booking,
|
||||
service: services?.find(s => s.id === booking.service_id),
|
||||
barber: barbers?.find(b => b.id === booking.barber_id),
|
||||
customer: users?.find(u => u.id === booking.customer_id),
|
||||
}));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static async createBooking(booking: Omit<Booking, 'id' | 'created_at'>) {
|
||||
if (localDatabase) {
|
||||
return await localDatabase.createBooking(booking);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static async updateBookingStatus(id: string, status: Booking['status']) {
|
||||
if (localDatabase) {
|
||||
return await localDatabase.updateBookingStatus(id, status);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static async checkAvailability(barberId: string, date: string, time: string) {
|
||||
if (localDatabase) {
|
||||
return await localDatabase.checkAvailability(barberId, date, time);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Reviews
|
||||
static async getReviewsByBarberId(barberId: string) {
|
||||
if (localDatabase) {
|
||||
return await localDatabase.getReviewsByBarberId(barberId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static async createReview(review: Omit<Review, 'id' | 'created_at'>) {
|
||||
if (localDatabase) {
|
||||
return await localDatabase.createReview(review);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Promotions
|
||||
static async getActivePromotions() {
|
||||
if (localDatabase) {
|
||||
return await localDatabase.getActivePromotions();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Users
|
||||
static async getUsers() {
|
||||
if (localDatabase) {
|
||||
return await localDatabase.getUsers();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static async getUserById(id: string) {
|
||||
if (localDatabase) {
|
||||
return await localDatabase.getUserById(id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Statistics
|
||||
static async getStats() {
|
||||
if (localDatabase) {
|
||||
return await localDatabase.getStats();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default LocalDataService;
|
||||
400
src/services/localDatabase.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { User, Barber, Service, Booking, Review, Promotion } from '../types';
|
||||
import { mockServices, mockBarbers, mockReviews, mockPromotions } from '../data/mockData';
|
||||
|
||||
// Storage keys
|
||||
const KEYS = {
|
||||
USERS: 'local_users',
|
||||
BARBERS: 'local_barbers',
|
||||
SERVICES: 'local_services',
|
||||
BOOKINGS: 'local_bookings',
|
||||
REVIEWS: 'local_reviews',
|
||||
PROMOTIONS: 'local_promotions',
|
||||
CURRENT_USER: 'current_user',
|
||||
};
|
||||
|
||||
// Default admin user
|
||||
const defaultAdmin: User = {
|
||||
id: 'admin-001',
|
||||
name: 'Administrador',
|
||||
email: 'admin@barbearia.pt',
|
||||
phone: '+351 912 345 678',
|
||||
role: 'admin',
|
||||
photo: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=200',
|
||||
loyalty_points: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Default barber user
|
||||
const defaultBarberUser: User = {
|
||||
id: 'barber-user-001',
|
||||
name: 'João Silva',
|
||||
email: 'barbeiro@barbearia.pt',
|
||||
phone: '+351 923 456 789',
|
||||
role: 'barber',
|
||||
photo: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=200',
|
||||
loyalty_points: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Default customer user
|
||||
const defaultCustomer: User = {
|
||||
id: 'customer-001',
|
||||
name: 'Cliente Demo',
|
||||
email: 'cliente@barbearia.pt',
|
||||
phone: '+351 934 567 890',
|
||||
role: 'customer',
|
||||
photo: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=200',
|
||||
loyalty_points: 100,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Default barber profile
|
||||
const defaultBarber: Barber = {
|
||||
id: 'barber-001',
|
||||
user_id: 'barber-user-001',
|
||||
specialty: 'Cortes Clássicos & Modernos',
|
||||
bio: 'Barbeiro profissional com 10 anos de experiência. Especialista em cortes clássicos e modernos.',
|
||||
rating: 4.9,
|
||||
availability: {
|
||||
monday: ['09:00', '10:00', '11:00', '14:00', '15:00', '16:00', '17:00'],
|
||||
tuesday: ['09:00', '10:00', '11:00', '14:00', '15:00', '16:00', '17:00'],
|
||||
wednesday: ['09:00', '10:00', '11:00', '14:00', '15:00', '16:00', '17:00'],
|
||||
thursday: ['09:00', '10:00', '11:00', '14:00', '15:00', '16:00', '17:00'],
|
||||
friday: ['09:00', '10:00', '11:00', '14:00', '15:00', '16:00', '17:00', '18:00'],
|
||||
saturday: ['10:00', '11:00', '12:00', '14:00', '15:00', '16:00'],
|
||||
sunday: [],
|
||||
},
|
||||
user: defaultBarberUser,
|
||||
is_active: true,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
class LocalDatabase {
|
||||
private initialized: boolean = false;
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
try {
|
||||
// Check if data already exists
|
||||
const users = await this.getUsers();
|
||||
|
||||
if (!users || users.length === 0) {
|
||||
// Initialize with default data
|
||||
await this.initializeDefaultData();
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
} catch (error) {
|
||||
console.error('Erro ao inicializar base de dados:', error);
|
||||
await this.initializeDefaultData();
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeDefaultData(): Promise<void> {
|
||||
// Save default users
|
||||
await this.saveUsers([defaultAdmin, defaultBarberUser, defaultCustomer]);
|
||||
|
||||
// Save default barbers
|
||||
const barbers = mockBarbers.map((b, index) => ({
|
||||
...b,
|
||||
id: `barber-${index + 1}`,
|
||||
user_id: index === 0 ? 'barber-user-001' : b.user_id,
|
||||
user: index === 0 ? defaultBarberUser : b.user,
|
||||
})) as Barber[];
|
||||
barbers[0] = defaultBarber;
|
||||
await this.saveBarbers(barbers);
|
||||
|
||||
// Save services
|
||||
const services = mockServices.map((s, index) => ({
|
||||
...s,
|
||||
id: `service-${index + 1}`,
|
||||
}));
|
||||
await this.saveServices(services);
|
||||
|
||||
// Save promotions
|
||||
const promotions = mockPromotions.map((p, index) => ({
|
||||
...p,
|
||||
id: `promo-${index + 1}`,
|
||||
}));
|
||||
await this.savePromotions(promotions);
|
||||
|
||||
// Save empty bookings array
|
||||
await this.saveBookings([]);
|
||||
|
||||
// Save empty reviews array
|
||||
await this.saveReviews([]);
|
||||
}
|
||||
|
||||
// Generic storage methods
|
||||
private async getData<T>(key: string): Promise<T[] | null> {
|
||||
try {
|
||||
const data = await AsyncStorage.getItem(key);
|
||||
return data ? JSON.parse(data) : null;
|
||||
} catch (error) {
|
||||
console.error(`Erro ao obter dados (${key}):`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async saveData<T>(key: string, data: T[]): Promise<void> {
|
||||
try {
|
||||
await AsyncStorage.setItem(key, JSON.stringify(data));
|
||||
} catch (error) {
|
||||
console.error(`Erro ao guardar dados (${key}):`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Users
|
||||
async getUsers(): Promise<User[] | null> {
|
||||
return this.getData<User>(KEYS.USERS);
|
||||
}
|
||||
|
||||
async saveUsers(users: User[]): Promise<void> {
|
||||
return this.saveData(KEYS.USERS, users);
|
||||
}
|
||||
|
||||
async getUserById(id: string): Promise<User | null> {
|
||||
const users = await this.getUsers();
|
||||
return users?.find(u => u.id === id) || null;
|
||||
}
|
||||
|
||||
async getUserByEmail(email: string): Promise<User | null> {
|
||||
const users = await this.getUsers();
|
||||
return users?.find(u => u.email.toLowerCase() === email.toLowerCase()) || null;
|
||||
}
|
||||
|
||||
async createUser(user: User): Promise<User> {
|
||||
const users = (await this.getUsers()) || [];
|
||||
const newUser = { ...user, id: user.id || `user-${Date.now()}`, created_at: new Date().toISOString() };
|
||||
users.push(newUser);
|
||||
await this.saveUsers(users);
|
||||
return newUser;
|
||||
}
|
||||
|
||||
async updateUser(id: string, updates: Partial<User>): Promise<User | null> {
|
||||
const users = (await this.getUsers()) || [];
|
||||
const index = users.findIndex(u => u.id === id);
|
||||
if (index === -1) return null;
|
||||
|
||||
users[index] = { ...users[index], ...updates };
|
||||
await this.saveUsers(users);
|
||||
return users[index];
|
||||
}
|
||||
|
||||
// Current user session
|
||||
async getCurrentUser(): Promise<User | null> {
|
||||
try {
|
||||
const data = await AsyncStorage.getItem(KEYS.CURRENT_USER);
|
||||
return data ? JSON.parse(data) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async setCurrentUser(user: User | null): Promise<void> {
|
||||
try {
|
||||
if (user) {
|
||||
await AsyncStorage.setItem(KEYS.CURRENT_USER, JSON.stringify(user));
|
||||
} else {
|
||||
await AsyncStorage.removeItem(KEYS.CURRENT_USER);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao definir utilizador actual:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Services
|
||||
async getServices(): Promise<Service[] | null> {
|
||||
return this.getData<Service>(KEYS.SERVICES);
|
||||
}
|
||||
|
||||
async saveServices(services: Service[]): Promise<void> {
|
||||
return this.saveData(KEYS.SERVICES, services);
|
||||
}
|
||||
|
||||
async getServiceById(id: string): Promise<Service | null> {
|
||||
const services = await this.getServices();
|
||||
return services?.find(s => s.id === id) || null;
|
||||
}
|
||||
|
||||
// Barbers
|
||||
async getBarbers(): Promise<Barber[] | null> {
|
||||
return this.getData<Barber>(KEYS.BARBERS);
|
||||
}
|
||||
|
||||
async saveBarbers(barbers: Barber[]): Promise<void> {
|
||||
return this.saveData(KEYS.BARBERS, barbers);
|
||||
}
|
||||
|
||||
async getBarberById(id: string): Promise<Barber | null> {
|
||||
const barbers = await this.getBarbers();
|
||||
return barbers?.find(b => b.id === id) || null;
|
||||
}
|
||||
|
||||
async getBarberByUserId(userId: string): Promise<Barber | null> {
|
||||
const barbers = await this.getBarbers();
|
||||
return barbers?.find(b => b.user_id === userId) || null;
|
||||
}
|
||||
|
||||
// Bookings
|
||||
async getBookings(): Promise<Booking[] | null> {
|
||||
return this.getData<Booking>(KEYS.BOOKINGS);
|
||||
}
|
||||
|
||||
async saveBookings(bookings: Booking[]): Promise<void> {
|
||||
return this.saveData(KEYS.BOOKINGS, bookings);
|
||||
}
|
||||
|
||||
async getBookingById(id: string): Promise<Booking | null> {
|
||||
const bookings = await this.getBookings();
|
||||
return bookings?.find(b => b.id === id) || null;
|
||||
}
|
||||
|
||||
async getBookingsByCustomerId(customerId: string): Promise<Booking[]> {
|
||||
const bookings = (await this.getBookings()) || [];
|
||||
return bookings.filter(b => b.customer_id === customerId);
|
||||
}
|
||||
|
||||
async getBookingsByBarberId(barberId: string): Promise<Booking[]> {
|
||||
const bookings = (await this.getBookings()) || [];
|
||||
return bookings.filter(b => b.barber_id === barberId);
|
||||
}
|
||||
|
||||
async getAllBookingsWithDetails(): Promise<Booking[]> {
|
||||
const bookings = (await this.getBookings()) || [];
|
||||
const services = (await this.getServices()) || [];
|
||||
const barbers = (await this.getBarbers()) || [];
|
||||
const users = (await this.getUsers()) || [];
|
||||
|
||||
return bookings.map(booking => ({
|
||||
...booking,
|
||||
service: services.find(s => s.id === booking.service_id),
|
||||
barber: barbers.find(b => b.id === booking.barber_id),
|
||||
customer: users.find(u => u.id === booking.customer_id),
|
||||
}));
|
||||
}
|
||||
|
||||
async createBooking(booking: Omit<Booking, 'id' | 'created_at'>): Promise<Booking> {
|
||||
const bookings = (await this.getBookings()) || [];
|
||||
const newBooking: Booking = {
|
||||
...booking,
|
||||
id: `booking-${Date.now()}`,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
bookings.push(newBooking);
|
||||
await this.saveBookings(bookings);
|
||||
return newBooking;
|
||||
}
|
||||
|
||||
async updateBookingStatus(id: string, status: Booking['status']): Promise<Booking | null> {
|
||||
const bookings = (await this.getBookings()) || [];
|
||||
const index = bookings.findIndex(b => b.id === id);
|
||||
if (index === -1) return null;
|
||||
|
||||
bookings[index].status = status;
|
||||
await this.saveBookings(bookings);
|
||||
return bookings[index];
|
||||
}
|
||||
|
||||
async checkAvailability(barberId: string, date: string, time: string): Promise<boolean> {
|
||||
const bookings = (await this.getBookings()) || [];
|
||||
const conflict = bookings.find(
|
||||
b => b.barber_id === barberId &&
|
||||
b.booking_date === date &&
|
||||
b.booking_time === time &&
|
||||
b.status !== 'cancelled'
|
||||
);
|
||||
return !conflict;
|
||||
}
|
||||
|
||||
// Reviews
|
||||
async getReviews(): Promise<Review[] | null> {
|
||||
return this.getData<Review>(KEYS.REVIEWS);
|
||||
}
|
||||
|
||||
async saveReviews(reviews: Review[]): Promise<void> {
|
||||
return this.saveData(KEYS.REVIEWS, reviews);
|
||||
}
|
||||
|
||||
async getReviewsByBarberId(barberId: string): Promise<Review[]> {
|
||||
const reviews = (await this.getReviews()) || [];
|
||||
const users = (await this.getUsers()) || [];
|
||||
|
||||
return reviews
|
||||
.filter(r => r.barber_id === barberId)
|
||||
.map(review => ({
|
||||
...review,
|
||||
user: users.find(u => u.id === review.user_id),
|
||||
}));
|
||||
}
|
||||
|
||||
async createReview(review: Omit<Review, 'id' | 'created_at'>): Promise<Review> {
|
||||
const reviews = (await this.getReviews()) || [];
|
||||
const newReview: Review = {
|
||||
...review,
|
||||
id: `review-${Date.now()}`,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
reviews.push(newReview);
|
||||
await this.saveReviews(reviews);
|
||||
return newReview;
|
||||
}
|
||||
|
||||
// Promotions
|
||||
async getPromotions(): Promise<Promotion[] | null> {
|
||||
return this.getData<Promotion>(KEYS.PROMOTIONS);
|
||||
}
|
||||
|
||||
async savePromotions(promotions: Promotion[]): Promise<void> {
|
||||
return this.saveData(KEYS.PROMOTIONS, promotions);
|
||||
}
|
||||
|
||||
async getActivePromotions(): Promise<Promotion[]> {
|
||||
const promotions = (await this.getPromotions()) || [];
|
||||
const now = new Date().toISOString().split('T')[0];
|
||||
return promotions.filter(p => p.is_active && p.start_date <= now && p.end_date >= now);
|
||||
}
|
||||
|
||||
// Statistics for admin
|
||||
async getStats(): Promise<{
|
||||
totalBookings: number;
|
||||
totalRevenue: number;
|
||||
activeBarbers: number;
|
||||
totalCustomers: number;
|
||||
pendingBookings: number;
|
||||
todayBookings: number;
|
||||
}> {
|
||||
const bookings = (await this.getBookings()) || [];
|
||||
const barbers = (await this.getBarbers()) || [];
|
||||
const users = (await this.getUsers()) || [];
|
||||
const services = (await this.getServices()) || [];
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const completedBookings = bookings.filter(b => b.status === 'completed');
|
||||
|
||||
const totalRevenue = completedBookings.reduce((sum, b) => {
|
||||
const service = services.find(s => s.id === b.service_id);
|
||||
return sum + (service?.price || 0);
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
totalBookings: bookings.length,
|
||||
totalRevenue,
|
||||
activeBarbers: barbers.filter(b => b.is_active).length,
|
||||
totalCustomers: users.filter(u => u.role === 'customer').length,
|
||||
pendingBookings: bookings.filter(b => b.status === 'pending').length,
|
||||
todayBookings: bookings.filter(b => b.booking_date === today).length,
|
||||
};
|
||||
}
|
||||
|
||||
// Clear all data
|
||||
async clearAll(): Promise<void> {
|
||||
await (AsyncStorage as any).multiRemove(Object.values(KEYS));
|
||||
this.initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
export const localDatabase = new LocalDatabase();
|
||||
export { defaultAdmin, defaultBarberUser, defaultCustomer, defaultBarber };
|
||||
17
src/services/supabase.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL || '';
|
||||
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY || '';
|
||||
|
||||
export const isSupabaseConfigured =
|
||||
supabaseUrl.length > 0 &&
|
||||
supabaseUrl.startsWith('https://') &&
|
||||
supabaseAnonKey.length > 0 &&
|
||||
!supabaseAnonKey.toLowerCase().includes('your') &&
|
||||
!supabaseUrl.toLowerCase().includes('your');
|
||||
|
||||
export const supabase = isSupabaseConfigured
|
||||
? createClient(supabaseUrl, supabaseAnonKey)
|
||||
: ({} as any);
|
||||
|
||||
export default supabase;
|
||||
81
src/types/index.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
role: 'customer' | 'barber' | 'admin';
|
||||
photo?: string;
|
||||
loyalty_points?: number;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface Barber {
|
||||
id: string;
|
||||
user_id: string;
|
||||
specialty: string;
|
||||
bio: string;
|
||||
rating: number;
|
||||
availability: Availability;
|
||||
user?: User;
|
||||
is_active?: boolean;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface Service {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
duration: number; // in minutes
|
||||
price: number;
|
||||
image?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface Booking {
|
||||
id: string;
|
||||
customer_id: string;
|
||||
barber_id: string;
|
||||
service_id: string;
|
||||
booking_date: string;
|
||||
booking_time: string;
|
||||
status: 'pending' | 'confirmed' | 'completed' | 'cancelled';
|
||||
customer?: User;
|
||||
barber?: Barber;
|
||||
service?: Service;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface Review {
|
||||
id: string;
|
||||
user_id: string;
|
||||
barber_id: string;
|
||||
service_id?: string;
|
||||
rating: number;
|
||||
comment: string;
|
||||
user?: User;
|
||||
barber?: Barber;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface Availability {
|
||||
[key: string]: string[]; // day: time slots
|
||||
}
|
||||
|
||||
export interface Promotion {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
discount_percentage: number;
|
||||
service_id?: string;
|
||||
is_active: boolean;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
image?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
user: User | null;
|
||||
session: any;
|
||||
loading: boolean;
|
||||
}
|
||||
4
tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"compilerOptions": {},
|
||||
"extends": "expo/tsconfig.base"
|
||||
}
|
||||