first commit

This commit is contained in:
Rodrigo Nogueira de Sousa
2026-05-22 11:12:19 +01:00
commit 47d2cc65c3
91 changed files with 18528 additions and 0 deletions

4
.env Normal file
View 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
View File

@@ -0,0 +1 @@
*.pbxproj -text

36
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

14
android/app/proguard-rules.pro vendored Normal file
View 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:

View 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>

View 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>

View 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>

View 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()
}
}

View 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)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View File

@@ -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>

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<resources>
<color name="splashscreen_background">#FFFFFF</color>
</resources>

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">AppBarber</string>
</resources>

View 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
View 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
View 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

Binary file not shown.

View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
module.exports = function(api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
};
};

150
database/schema.sql Normal file
View 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
View 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
View 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
View 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)

View 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 */;
}

View File

@@ -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

Binary file not shown.

View File

@@ -0,0 +1,3 @@
//
// Use this file to import your target's public headers that you would like to expose to Swift.
//

View 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
}
}

View File

@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"version" : 1,
"author" : "expo"
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "expo"
}
}

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

53
ios/AppBarber/Info.plist Normal file
View 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>

View 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>

View 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
View 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

View File

@@ -0,0 +1,4 @@
{
"expo.jsEngine": "hermes",
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true"
}

7
metro.config.js Normal file
View 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

File diff suppressed because it is too large Load Diff

43
package.json Normal file
View 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"
}
}

View 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;

View 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;

View 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;

View 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
View 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
View 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
View 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',
},
];

View 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
View 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;
};

View 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;

View 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;

View 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;

View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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}>
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;

View 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;

View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
{
"compilerOptions": {},
"extends": "expo/tsconfig.base"
}