1 Commits

Author SHA1 Message Date
Antoni Nuñez Romeu 8615e213de PIP PLATFORM ended
Run Tests on Branches / Backend Tests (push) Successful in 3m28s
Run Tests on Branches / Frontend Tests (push) Successful in 3m25s
Run Tests on Branches / PIP Tests (push) Failing after 9s
2026-07-01 17:19:03 +02:00
437 changed files with 4600 additions and 49861 deletions
+13 -66
View File
@@ -7,69 +7,29 @@ on:
- main - main
jobs: jobs:
detect-changes:
name: Detect Changes
runs-on: ubuntu-latest
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
backend:
- 'apps/backend/**'
- 'packages/**'
frontend:
- 'apps/frontend/**'
- 'packages/**'
test-backend: test-backend:
name: Backend Tests
needs: detect-changes
if: needs.detect-changes.outputs.backend == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'
- name: Install dependencies - name: Install dependencies
run: npm ci run: cd backend && npm ci
- name: Run tests - name: Run tests
run: npm test --workspace=farma-clic-backend -- --ci run: cd backend && npm test -- --ci
test-frontend: test-frontend:
name: Frontend Tests
needs: detect-changes
if: needs.detect-changes.outputs.frontend == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'
- name: Install dependencies - name: Install dependencies
run: npm ci run: cd frontend && npm ci
- name: Run tests - name: Run tests
run: npm test --workspace=farma-clic-frontend -- --run --reporter=basic run: cd frontend && npm test -- --run --reporter=basic
build-backend: build-backend:
name: Build Backend needs: [ test-backend, test-frontend ]
needs: [ detect-changes, test-backend ]
if: |
always() &&
needs.detect-changes.outputs.backend == 'true' &&
(needs.test-backend.result == 'success' || needs.test-backend.result == 'skipped')
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- name: Log in to Gitea registry - name: Log in to Gitea registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
@@ -80,22 +40,17 @@ jobs:
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: ./apps/backend/Dockerfile file: ./backend/Dockerfile
push: true push: true
tags: | tags: |
git.hacecalor.net/ichitux/farmafinder-backend:latest git.hacecalor.net/ichitux/farmafinder-backend:latest
git.hacecalor.net/ichitux/farmafinder-backend:${{ gitea.sha }} git.hacecalor.net/ichitux/farmafinder-backend:${{ gitea.sha }}
build-frontend: build-frontend:
name: Build Frontend needs: [ test-backend, test-frontend ]
needs: [ detect-changes, test-frontend ]
if: |
always() &&
needs.detect-changes.outputs.frontend == 'true' &&
(needs.test-frontend.result == 'success' || needs.test-frontend.result == 'skipped')
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- name: Log in to Gitea registry - name: Log in to Gitea registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
@@ -105,20 +60,14 @@ jobs:
- name: Build and push frontend image - name: Build and push frontend image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: ./apps/frontend context: ./frontend
push: true push: true
tags: | tags: |
git.hacecalor.net/ichitux/farmafinder-frontend:latest git.hacecalor.net/ichitux/farmafinder-frontend:latest
git.hacecalor.net/ichitux/farmafinder-frontend:${{ gitea.sha }} git.hacecalor.net/ichitux/farmafinder-frontend:${{ gitea.sha }}
deploy: deploy:
name: Deploy needs: [ build ]
needs: [ detect-changes, build-backend, build-frontend ]
if: |
always() &&
(needs.build-backend.result == 'success' || needs.build-backend.result == 'skipped') &&
(needs.build-frontend.result == 'success' || needs.build-frontend.result == 'skipped') &&
(needs.detect-changes.outputs.backend == 'true' || needs.detect-changes.outputs.frontend == 'true')
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: SSH to remote server - name: SSH to remote server
@@ -130,7 +79,5 @@ jobs:
port: ${{ secrets.PORT }} port: ${{ secrets.PORT }}
script: | script: |
cd /docker/FarmaFinder cd /docker/FarmaFinder
git pull
docker compose pull docker compose pull
docker compose down --remove-orphans
docker compose up -d docker compose up -d
+24 -51
View File
@@ -6,89 +6,62 @@ on:
- 'main' - 'main'
jobs: jobs:
detect-changes:
name: Detect Changes
runs-on: ubuntu-latest
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
frontend-mobile: ${{ steps.filter.outputs.frontend-mobile }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
backend:
- 'apps/backend/**'
- 'packages/**'
frontend:
- 'apps/frontend/**'
- 'packages/**'
frontend-mobile:
- 'apps/frontend-mobile/**'
- 'packages/**'
test-backend: test-backend:
name: Backend Tests name: Backend Tests
needs: detect-changes
if: needs.detect-changes.outputs.backend == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v4 uses: actions/checkout@v3
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v3
with: with:
node-version: '24' node-version: '20'
cache: 'npm' cache: 'npm'
cache-dependency-path: backend/package-lock.json
- name: Install Dependencies - name: Install Backend Dependencies
run: npm ci run: cd backend && npm ci
- name: Run Backend Tests - name: Run Backend Tests
run: npm test --workspace=farma-clic-backend -- --ci run: cd backend && npm test -- --ci
test-frontend: test-frontend:
name: Frontend Tests name: Frontend Tests
needs: detect-changes
if: needs.detect-changes.outputs.frontend == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v4 uses: actions/checkout@v3
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v3
with: with:
node-version: '24' node-version: '20'
cache: 'npm' cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install Dependencies - name: Install Frontend Dependencies
run: npm ci run: cd frontend && npm ci
- name: Run Frontend Tests - name: Run Frontend Tests
run: npm test --workspace=farma-clic-frontend -- --run --reporter=basic run: cd frontend && npm test -- --run --reporter=basic
test-frontend-mobile: test-pip:
name: Frontend Mobile Tests name: PIP Tests
needs: detect-changes
if: needs.detect-changes.outputs.frontend-mobile == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v4 uses: actions/checkout@v3
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v3
with: with:
node-version: '24' node-version: '20'
cache: 'npm' cache: 'npm'
cache-dependency-path: pip-platform/package-lock.json
- name: Install Dependencies - name: Install PIP dependences
run: npm ci run: cd pip-platform && npm ci
- name: Run Frontend Mobile Tests - name: Run PIP Tests
run: npm test --workspace=frontend-mobile -- --ci run: cd pip-platform && npm test -- --run --reporter=basic
continue-on-error: true
+184
View File
@@ -0,0 +1,184 @@
name: iOS Build & Deploy
on:
push:
tags:
- "ios-v*"
workflow_dispatch:
inputs:
upload_to_testflight:
description: "Upload to TestFlight after build"
required: false
default: "true"
type: choice
options:
- "true"
- "false"
concurrency:
group: ios-build-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install backend dependencies & test
run: cd backend && npm ci && npm test -- --ci
- name: Install frontend dependencies & test
run: cd frontend && npm ci && npm test -- --run --reporter=basic
build-ios:
name: Build iOS App
needs: test
runs-on: macos-14
env:
SCHEME: App
WORKSPACE: ios/App/App.xcworkspace
ARCHIVE_PATH: build/App.xcarchive
EXPORT_OPTIONS: scripts/export-options.plist
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install root dependencies
run: npm ci
- name: Install frontend dependencies
run: cd frontend && npm ci
- name: Build web bundle
run: npm run build:web
- name: Ensure iOS platform & sync Capacitor
run: |
if [ ! -d "ios/App/App.xcodeproj" ]; then
echo "Adding iOS platform..."
npx cap add ios
fi
npx cap sync ios
- name: Install CocoaPods
run: cd ios/App && pod install --repo-update
- name: Install Apple certificates & provisioning profile
uses: apple-actions/import-codesign-certs@v2
with:
p12-file-base64: ${{ secrets.IOS_P12_BASE64 }}
p12-password: ${{ secrets.IOS_P12_PASSWORD }}
- name: Install provisioning profile
run: |
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
echo "${{ secrets.IOS_PROVISION_PROFILE_BASE64 }}" | base64 --decode > ~/Library/MobileDevice/Provisioning\ Profiles/${{ secrets.IOS_PROVISION_PROFILE_UUID }}.mobileprovision
- name: Set version from tag
if: startsWith(github.ref, 'refs/tags/ios-v')
run: |
TAG_VERSION="${GITHUB_REF#refs/tags/ios-v}"
if ! echo "$TAG_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::Invalid tag format '$TAG_VERSION'. Expected semver: X.Y.Z"
exit 1
fi
MAJOR=$(echo "$TAG_VERSION" | cut -d. -f1)
MINOR=$(echo "$TAG_VERSION" | cut -d. -f2)
PATCH=$(echo "$TAG_VERSION" | cut -d. -f3)
BUILD=${{ github.run_number }}
echo "Setting MARKETING_VERSION=$MAJOR.$MINOR.$PATCH"
echo "Setting CURRENT_PROJECT_VERSION=$BUILD"
sed -i '' "s/MARKETING_VERSION = [^;]*;/MARKETING_VERSION = $MAJOR.$MINOR.$PATCH;/" ios/App/App.xcodeproj/project.pbxproj
sed -i '' "s/CURRENT_PROJECT_VERSION = [^;]*;/CURRENT_PROJECT_VERSION = $BUILD;/" ios/App/App.xcodeproj/project.pbxproj
- name: Build Xcode archive
run: |
set -o pipefail
xcodebuild archive \
-workspace "$WORKSPACE" \
-scheme "$SCHEME" \
-configuration Release \
-destination "generic/platform=iOS" \
-archivePath "$ARCHIVE_PATH" \
-allowProvisioningUpdates \
CODE_SIGN_STYLE=Manual \
DEVELOPMENT_TEAM="${{ secrets.IOS_TEAM_ID }}" \
PROVISIONING_PROFILE_SPECIFIER="${{ secrets.IOS_PROVISION_PROFILE_NAME }}" \
2>&1 | xcpretty --color
- name: Export IPA
run: |
plutil -replace teamID -string "${{ secrets.IOS_TEAM_ID }}" "$EXPORT_OPTIONS"
plutil -replace provisioningProfiles.net.hacecalor.farmafinder -string "${{ secrets.IOS_PROVISION_PROFILE_NAME }}" "$EXPORT_OPTIONS"
set -o pipefail
xcodebuild -exportArchive \
-archivePath "$ARCHIVE_PATH" \
-exportOptionsPlist "$EXPORT_OPTIONS" \
-exportPath build \
-allowProvisioningUpdates \
2>&1 | xcpretty --color
- name: Upload artifact (IPA)
uses: actions/upload-artifact@v4
with:
name: FarmaFinder-iOS
path: build/*.ipa
retention-days: 30
- name: Upload to TestFlight
if: >
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ios-v')) ||
(github.event_name == 'workflow_dispatch' && inputs.upload_to_testflight == 'true')
env:
APP_STORE_API_KEY_ID: ${{ secrets.APP_STORE_API_KEY_ID }}
APP_STORE_API_ISSUER_ID: ${{ secrets.APP_STORE_API_ISSUER_ID }}
run: |
IPA_PATH=$(find build -name "*.ipa" -maxdepth 1 | head -1)
if [ -z "$IPA_PATH" ]; then
echo "::error::No IPA found in build/"
exit 1
fi
API_PRIVATE_KEY_PATH="$RUNNER_TEMP/app_store_api_key.p8"
echo "${{ secrets.APP_STORE_API_PRIVATE_KEY }}" > "$API_PRIVATE_KEY_PATH"
xcrun notarytool submit "$IPA_PATH" \
--key-id "$APP_STORE_API_KEY_ID" \
--issuer "$APP_STORE_API_ISSUER_ID" \
--key "$API_PRIVATE_KEY_PATH" \
--wait
rm -f "$API_PRIVATE_KEY_PATH"
- name: Summary
run: |
IPA_PATH=$(find build -name "*.ipa" -maxdepth 1 | head -1)
if [ -n "$IPA_PATH" ]; then
IPA_SIZE=$(du -h "$IPA_PATH" | cut -f1)
echo "### iOS Build Summary" >> $GITHUB_STEP_SUMMARY
echo "| Item | Value |" >> $GITHUB_STEP_SUMMARY
echo "|------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| IPA Size | $IPA_SIZE |" >> $GITHUB_STEP_SUMMARY
echo "| Tag | ${GITHUB_REF#refs/tags/} |" >> $GITHUB_STEP_SUMMARY
echo "| Build # | ${{ github.run_number }} |" >> $GITHUB_STEP_SUMMARY
else
echo "::error::IPA not found in build/"
exit 1
fi
-1
View File
@@ -2,7 +2,6 @@ node_modules/
dist/ dist/
dev-dist/ dev-dist/
build/ build/
.turbo/
*.sqlite *.sqlite
*.sqlite3 *.sqlite3
.env .env
-1
View File
@@ -1 +0,0 @@
{"pid":10782,"startedAt":1783578681151}
@@ -1,141 +0,0 @@
# Plan: Dark Mode para FarmaFinder Mobile
## Objetivo
Añadir soporte de modo oscuro a la app móvil con:
- Detección automática del modo oscuro del sistema
- Toggle manual en la pantalla de perfil (Configuración)
- Persistencia de la preferencia del usuario
## Arquitectura
### 1. Theme Store (Zustand + AsyncStorage)
**Nuevo archivo: `store/themeStore.ts`**
- Estado: `mode: 'light' | 'dark' | 'system'`
- Persistencia con `@react-native-async-storage/async-storage`
- Acciones: `setMode()`, `init()` (carga del storage al arrancar)
- Por defecto: `'system'` (respeta el dispositivo)
### 2. Hook `useThemeColor`
**Nuevo archivo: `hooks/useThemeColor.ts`**
- Usa `useColorScheme()` de React Native para detectar el color del sistema
- Combina con el `mode` del themeStore para determinar el tema efectivo
- Retorna el objeto `colors` apropiado (light o dark)
- Los componentes consumirán este hook en vez de importar `colors` directamente
### 3. Paleta de colores oscura
**Modificar: `constants/theme.ts`**
Añadir exportación `darkColors` con la paleta oscura:
| Token | Light (actual) | Dark (nuevo) |
|---|---|---|
| `background` | `#fbfbfb` | `#121212` |
| `card` | `#ffffff` | `#1e1e1e` |
| `surfaceLow` | `#f2f4f5` | `#2a2a2a` |
| `surface` | `#eceeef` | `#333333` |
| `surfaceHigh` | `#e6e8e9` | `#3a3a3a` |
| `border` | `#c0c9bb` | `#3a3a3a` |
| `separator` | `#c0c9bb` | `#3a3a3a` |
| `text` | `#111417` | `#f0f0f0` |
| `textSecondary` | `#41493e` | `#a0a0a0` |
| `textInverse` | `#ffffff` | `#111417` |
| `primaryContainer` | `#cfead0` | `#1a3a1c` |
| `onPrimaryContainer` | `#0d2b12` | `#cfead0` |
| `secondaryContainer` | `#dbe7ff` | `#1a2a4a` |
| `tertiaryContainer` | `#efe7ff` | `#2a1a4a` |
| `dangerContainer` | `#feecec` | `#3a1a1a` |
| `accentWarm` | `#f5a97a` | `#f5a97a` (sin cambio) |
| `primary`, `secondary`, `tertiary`, `scanButton`, `success`, `danger`, `warning` | (sin cambios) | (sin cambios) |
### 4. Provider wrapper
**Nuevo archivo: `components/ThemeProvider.tsx`**
- Lee el `mode` del themeStore y el `useColorScheme()` del sistema
- Calcula el tema efectivo (`light` o `dark`)
- Provee `colors` y `isDark` vía React Context
- Carga la preferencia guardada en AsyncStorage al montar
### 5. Integración en Root Layout
**Modificar: `app/_layout.tsx`**
- Envolver toda la app con `<ThemeProvider>`
- El `StatusBar` se ajusta: `style={isDark ? 'light' : 'auto'}`
- Los colores del Stack header se leen del contexto
### 6. Toggle en el perfil
**Modificar: `app/(tabs)/profile.tsx`**
Añadir en la pantalla de perfil (antes de "Configuración") un nuevo item de menú:
- Icono: `moon-outline` / `sunny-outline` según el tema actual
- Texto: "Modo de pantalla"
- Al pulsar, mostrar un picker/modal con 3 opciones:
- **Sistema** (icono: phone-portrait) - respeta el SO
- **Claro** (icono: sunny) - fuerza light
- **Oscuro** (icono: moon) - fuerza dark
- El toggle se muestra inline (sin modal extra) tipo Segmented Control
### 7. Actualizar todos los screens y components
Cada archivo que importa `colors` debe cambiar a usar el hook `useThemeColor()`:
| Archivo | Cambio |
|---|---|
| `app/_layout.tsx` | Usa ThemeProvider + context para header colors |
| `app/(tabs)/_layout.tsx` | Tab bar y header leen colors del context |
| `app/(tabs)/index.tsx` | `const { colors } = useThemeColor()` |
| `app/(tabs)/search.tsx` | `const { colors } = useThemeColor()` |
| `app/(tabs)/scan.tsx` | `const { colors } = useThemeColor()` |
| `app/(tabs)/alerts.tsx` | `const { colors } = useThemeColor()` |
| `app/(tabs)/profile.tsx` | `const { colors, isDark } = useThemeColor()` |
| `app/(tabs)/map.tsx` | `const { colors } = useThemeColor()` |
| `app/medicine/[id].tsx` | `const { colors } = useThemeColor()` |
| `app/pharmacy/[id].tsx` | `const { colors } = useThemeColor()` |
| `app/auth/login.tsx` | `const { colors } = useThemeColor()` |
| `app/auth/register.tsx` | `const { colors } = useThemeColor()` |
| `components/SearchBar.tsx` | `const { colors } = useThemeColor()` |
| `components/MedicineCard.tsx` | `const { colors } = useThemeColor()` |
| `components/LoadingSpinner.tsx` | `const { colors } = useThemeColor()` |
| `components/StockBadge.tsx` | Añade colores dark para badges |
| `components/BarcodeScanner.tsx` | `const { colors } = useThemeColor()` |
**Nota sobre StyleSheet**: Los `StyleSheet.create()` se ejecutan una vez. Para que los estilos se actualicen con el tema, las propiedades que dependen de `colors` deben aplicarse vía `style` inline o moverse a funciones que retornen estilos dinámicos. La estrategia más limpia es:
- Los colores de fondo/texto van como inline styles
- Las estructuras (flex, padding, borderRadius) se quedan en `StyleSheet.create`
### 8. Colores hardcodeados
Los siguientes archivos tienen colores inline hardcodeados que necesitan variante dark:
- **`StockBadge.tsx`**: `#eaf7ec`, `#fff3cd`, `#feecec` → añadir tokens al tema
- **`search.tsx`**: suggestions con `#ffffff`, `#f0f7ff`, etc. → usar colores del tema
- **`profile.tsx`**: feedback `#eaf7ec`, `#cfead0`, `#fecaca` → usar colores del tema
- **`home/index.tsx`**: `rgba(255,255,255,0.2/0.3)` para iconos → ajustar opacidad
### 9. app.json
**Modificar: `app.json`**
Cambiar `"userInterfaceStyle": "light"``"userInterfaceStyle": "automatic"`
## Orden de ejecución
1. `store/themeStore.ts` (nuevo)
2. `hooks/useThemeColor.ts` (nuevo)
3. `constants/theme.ts` (añadir `darkColors`)
4. `components/ThemeProvider.tsx` (nuevo)
5. `app/_layout.tsx` (envolver con ThemeProvider)
6. `app/(tabs)/_layout.tsx` (colores dinámicos)
7. `app/(tabs)/profile.tsx` (toggle de tema)
8. Todos los screens y components (migrar a hook)
9. `app.json` (userInterfaceStyle: automatic)
10. Corregir colores hardcodeados
## Verificación
1. Ejecutar `npx expo start` y verificar que la app arranca sin errores
2. Probar en iOS/Android: el tema respeta la configuración del sistema
3. Ir a Perfil > Modo de pantalla y cambiar entre Claro/Oscuro/Sistema
4. Verificar que el cambio es inmediato sin recargar la app
5. Cerrar y reabrir la app → la preferencia persiste
6. Verificar que todos los screens se ven bien en ambos modos (sin texto invisible, sin fondos transparentes)
7. Probar tablets ( responsive)
+209
View File
@@ -0,0 +1,209 @@
# FarmaFinder — TSI Barcode Scanning + Capacitor 8 Upgrade
## Overview
Two sequential phases:
1. **Phase A** — Upgrade Capacitor 6 → 8 (prerequisite for latest barcode scanner plugin)
2. **Phase B** — Implement real TSI barcode scanning with manual CIP entry fallback
---
## Phase A: Upgrade Capacitor 6 → 8
### A1. Upgrade Capacitor packages (root)
```bash
npm install @capacitor/cli@latest @capacitor/core@latest \
@capacitor/android@latest @capacitor/ios@latest \
@capacitor/app@latest @capacitor/splash-screen@latest \
@capacitor/status-bar@latest
```
This bumps all `@capacitor/*` from `^6.x` to `^8.x` in `package.json`.
### A2. Update `android/variables.gradle`
Replace all 13 version variables to meet Capacitor 8 minimums:
| Variable | Current | New |
|---|---|---|
| `minSdkVersion` | 22 | **24** |
| `compileSdkVersion` | 34 | **36** |
| `targetSdkVersion` | 34 | **36** |
| `androidxActivityVersion` | 1.8.0 | **1.11.0** |
| `androidxAppCompatVersion` | 1.6.1 | **1.7.1** |
| `androidxCoordinatorLayoutVersion` | 1.2.0 | **1.3.0** |
| `androidxCoreVersion` | 1.12.0 | **1.17.0** |
| `androidxFragmentVersion` | 1.6.2 | **1.8.9** |
| `coreSplashScreenVersion` | 1.0.1 | **1.2.0** |
| `androidxWebkitVersion` | 1.9.0 | **1.14.0** |
| `junitVersion` | 4.13.2 | 4.13.2 (same) |
| `androidxJunitVersion` | 1.1.5 | **1.3.0** |
| `androidxEspressoCoreVersion` | 3.5.1 | **3.7.0** |
| `cordovaAndroidVersion` | 10.1.1 | **14.0.1** |
### A3. Update `ios/App/Podfile`
- Line 3: Change `platform :ios, '13.0'``platform :ios, '15.0'`
### A4. Update `android/app/src/main/AndroidManifest.xml`
- Add `density` to `android:configChanges` on line 13:
```
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|density"
```
### A5. Update `android/build.gradle` (Gradle plugin)
- Update Android Gradle Plugin from `8.2.1` → `8.13.0`
- Update Google Services plugin from `4.4.0` → `4.4.4`
- Update Gradle wrapper to `8.14.3` in `android/gradle/wrapper/gradle-wrapper.properties`
### A6. Run Capacitor sync
```bash
npx cap sync
```
### A7. Verify build
- Open `android/` in Android Studio — confirm Gradle sync succeeds
- Open `ios/App` in Xcode — confirm pod install succeeds and build compiles
---
## Phase B: Real TSI Barcode Scanning
### B1. Install barcode scanning plugin
From `frontend/`:
```bash
npm install @capacitor-mlkit/barcode-scanning@^8.1.0
npm install barcode-detector # web polyfill for PWA browser fallback
```
From root:
```bash
npx cap sync
```
**Plugin:** `@capacitor-mlkit/barcode-scanning` v8.1.0 — Google ML Kit engine, 91K+ weekly npm downloads, supports Code 128 (TSI physical card barcode) and QR Code (virtual TSI card).
### B2. Native manifest verification (no changes expected)
| Platform | File | Required | Status |
|---|---|---|---|
| Android | `AndroidManifest.xml:44` | `CAMERA` permission | Already present |
| iOS | `Info.plist:52-53` | `NSCameraUsageDescription` | Already present |
### B3. Rewrite `frontend/src/views/ScannerView.jsx`
**Remove:**
- `MOCK_TSI_CARDS` array and all mock card state/UI
- `navigator.mediaDevices.getUserMedia()` camera logic (lines 40-73)
- Auto-scan `setTimeout` simulation (lines 98-105)
- Simulated scan fallback panel (lines 170-198)
- `selectedMockCard` state
**Add:**
- Import `BarcodeScanner`, `BarcodeFormat`, `LensFacing` from `@capacitor-mlkit/barcode-scanning`
- Import `Capacitor` from `@capacitor/core` (platform detection)
- Import `barcode-detector/polyfill` at top (web fallback)
**New scan flow:**
```
1. Component mounts → show "Start Scanning" button + manual CIP input
2. User taps "Start Scanning":
a. Check BarcodeScanner.isSupported() → error if not
b. Check/request camera permission via BarcodeScanner.checkPermissions() / requestPermissions()
c. Call BarcodeScanner.scan({ formats: [BarcodeFormat.Code128, BarcodeFormat.QrCode], autoZoom: true })
d. Extract barcodes[0].rawValue (NOTE: can be undefined per v8 breaking change — handle gracefully)
e. Validate CIP format: /^[A-Z0-9]{16}$/i
f. If valid → fetchPrescriptions(cip) → show prescriptions
g. If invalid → show error "Invalid card barcode. Try manual entry."
3. Manual CIP input:
- Text input field with placeholder "Enter CIP code manually"
- "Submit" button → validates format → fetchPrescriptions(cip)
4. PWA/browser fallback:
- Detect: !Capacitor.isNativePlatform()
- Show message: "Barcode scanning requires the mobile app. Enter your CIP code manually below."
- Show only the manual CIP input (no scan button)
```
**Phases (simplified):**
- `idle` — initial state, scan button + manual input visible
- `scanning` — ML Kit native UI active (camera takes over)
- `prescriptions` — results display (keep existing prescription card UI)
- `error` — permission denied / unsupported / invalid barcode
**Keep unchanged:**
- `playBeep()` function
- `fetchPrescriptions(cip)` function
- `handlePickPrescription(rx)` function
- `handleBack()` function
- Prescription display UI (lines 201-249)
- `onSelectMedicine` callback prop
### B4. Rewrite `frontend/src/views/ScannerView.css`
**Remove:**
- `.scanner-viewport-wrap`, `.scanner-camera-container`, `.scanner-video`, `.scanner-frame`, `.scanner-laser`, `.scanner-hint` (old camera overlay)
- `.scanner-simulate-panel`, `.mock-cards-list`, `.mock-card-btn`, `.simulate-scan-btn`, `.simulate-label`, `.mock-card-icon`, `.mock-card-info` (mock card panel)
- `.scanner-placeholder` with spinner (old loading state)
**Add:**
- `.scan-start-btn` — large centered button with camera icon, teal background (`#0f766e`), full-width
- `.manual-cip-section` — input group with text field + submit button
- `.cip-input` — text input styled to match existing dark theme, monospace font
- `.cip-submit-btn` — teal button matching scan button style
- `.pwa-notice` — info banner for browser users
- `.scan-error-panel` — error state with icon and retry button
### B5. No changes to other files
- `HomeView.jsx` — "Scan TSI Card" button still navigates to ScannerView
- `PublicView.jsx` — screen routing and `onSelectMedicine` handoff unchanged
- `backend/server.js` — user handles `/api/tsi/:cip/prescriptions`
- `capacitor.config.json` — no scanner-specific config needed
---
## Files Modified (summary)
| File | Phase | Change |
|---|---|---|
| `package.json` | A1 | Bump all `@capacitor/*` to ^8.x |
| `frontend/package.json` | B1 | Add `@capacitor-mlkit/barcode-scanning@^8.1.0` + `barcode-detector` |
| `android/variables.gradle` | A2 | Update all 13 SDK/library versions |
| `ios/App/Podfile` | A3 | iOS deployment target 13.0 → 15.0 |
| `android/app/src/main/AndroidManifest.xml` | A4 | Add `density` to configChanges |
| `android/build.gradle` | A5 | AGP 8.2.1 → 8.13.0, Google Services 4.4.0 → 4.4.4 |
| `android/gradle/wrapper/gradle-wrapper.properties` | A5 | Gradle wrapper → 8.14.3 |
| `frontend/src/views/ScannerView.jsx` | B3 | Full rewrite — real barcode scanning + manual input |
| `frontend/src/views/ScannerView.css` | B4 | Remove old camera/mock styles, add new scan UI |
---
## Known Issues
- **Android NullPointerException bug** (GitHub capawesome-team/capacitor-mlkit #160/#324) — affects `startScan()` in v8.0.1. Workaround: use `scan()` method (built-in native UI) instead of `startScan()` (custom WebView mode). Plan uses `scan()`, so this shouldn't hit us.
- **`rawValue` can be undefined** in v8.x — handle with null check before CIP validation.
- **Capacitor 8 requires Xcode 26+** — must have latest Xcode installed.
- **Gradle 8.14.3 requires JDK 21+** — confirm JDK 21 is available.
- **`@capacitor-mlkit/barcode-scanning` v6.x.x is deprecated** — this is why we upgrade to Capacitor 8 first (v8.x.x plugin requires Cap 8).
---
## Implementation Order
| Step | Phase | Estimated Time |
|---|---|---|
| A1-A6 | Capacitor upgrade | ~30 min |
| A7 | Verify build (Android + iOS) | ~30 min |
| B1 | Install barcode plugin | ~5 min |
| B3 | Rewrite ScannerView.jsx | ~2 hrs |
| B4 | Update ScannerView.css | ~30 min |
| B5 | Test on Android device | ~30 min |
| B5 | Test on iOS device | ~30 min |
Total: ~4-5 hours
View File
+237 -249
View File
@@ -1,161 +1,54 @@
# FarmaFinder # 💊 FarmaFinder
A web application to search for medicines from the official Spanish CIMA database and find which pharmacies sell them. A web application to search for medicines from the official Spanish CIMA database and find which pharmacies sell them.
## Features ## Features
### Web App (Desktop/PWA) - 🔍 **Real-time medicine search** from CIMA API (Agencia Española de Medicamentos)
- Real-time medicine search from CIMA API (Agencia Espanola de Medicamentos) - 💾 **Redis caching** for improved performance
- Redis caching for improved performance - 📍 View pharmacies that sell a specific medicine
- View pharmacies that sell a specific medicine - 💰 See prices and stock availability
- See prices and stock availability - 📱 Responsive design for mobile and desktop
- Responsive design for mobile and desktop - ⚙️ **Admin Panel** - Manage pharmacies and link medicines
- Admin Panel - Manage pharmacies and link medicines - 🔐 **Secure authentication** - Login required to access admin features
- Secure authentication - Login required to access admin features
- Add, edit, and delete pharmacies - Add, edit, and delete pharmacies
- Search medicines from CIMA database - Search medicines from CIMA database
- Link medicines to pharmacies with prices and stock - Link medicines to pharmacies with prices and stock
### Mobile App (React Native) ## 🛠️ Tech Stack
- Native iOS/Android experience with Expo
- Medicine search with real-time results
- Interactive map with pharmacy markers
- Barcode scanner for quick medicine lookup
- Push notifications for availability alerts
- Biometric authentication (Face ID / Touch ID)
- Offline cache for favorite medicines
## Tech Stack - **Frontend**: React + Vite
- **Backend**: Node.js + Express
- **Database**: SQLite (for pharmacies and relationships)
- **Cache**: Redis
- **External API**: CIMA (Centro de Información online de Medicamentos de la AEMPS)
| App | Stack | ## 📋 Prerequisites
|-----|-------|
| Backend | Node.js + Express, SQLite, Redis |
| Frontend (Web) | React + Vite, Capacitor |
| Frontend (Mobile) | Expo SDK 57 + React Native, Zustand, Axios + TanStack Query |
| Build system | Turborepo |
| Package manager | npm workspaces |
## Prerequisites - Node.js (v18 or higher)
- npm or yarn
- **Redis server** (v6.0 or higher)
- Node.js v20+ ## 🐳 Docker Setup (Recommended)
- npm v9+
- Redis server v6.0+ (or use Docker)
- Docker + Docker Compose v2 (optional, for containerized deployment)
## Project Structure Runs the full stack (backend, frontend, Redis) with a single command.
This is a **Turborepo monorepo**. All applications live under `apps/`: **Prerequisites**: Docker and Docker Compose v2.
```
FarmaFinder/
├── package.json # Root: workspaces + turbo scripts
├── turbo.json # Turborepo task configuration
├── docker-compose.yml # Full stack: backend + frontend + Redis + Postgres
├── apps/
│ ├── backend/ # Node.js + Express API
│ │ ├── Dockerfile
│ │ ├── server.js # Express server and API routes
│ │ ├── cima-service.js # CIMA API integration with Redis cache
│ │ ├── redis-client.js # Redis connection configuration
│ │ ├── seed.js # Database seeding script
│ │ ├── create-admin.js # Admin user creation script
│ │ └── package.json
│ │
│ ├── frontend/ # React + Vite (Desktop/PWA)
│ │ ├── Dockerfile
│ │ ├── nginx.conf # Nginx config for Docker
│ │ ├── src/
│ │ │ ├── components/ # React components
│ │ │ ├── views/ # View components (Public/Admin)
│ │ │ ├── App.jsx
│ │ │ └── main.jsx
│ │ └── package.json
│ │
│ ├── frontend-mobile/ # Expo + React Native (iOS/Android)
│ │ ├── app/ # Expo Router screens
│ │ ├── components/
│ │ ├── services/
│ │ ├── store/
│ │ └── package.json
│ │
│ ├── scraper/ # Puppeteer scraper (standalone)
│ │ └── package.json
│ │
│ └── pip-platform/ # Python FastAPI platform (separate docker-compose)
│ ├── Dockerfile
│ ├── docker-compose.yml
│ └── pyproject.toml
├── API/ # Shared API source files
├── scripts/ # Build/utility scripts
└── docs/ # Documentation
```
## Quick Start
### Install dependencies
```bash ```bash
npm install # Copy and configure environment (optional — defaults work for local dev)
``` cp backend/.env.example backend/.env
# Edit backend/.env to set SESSION_SECRET and any other vars
This installs all workspace dependencies (backend, frontend, mobile, scraper) via npm workspaces.
### Development
```bash
# Start everything (backend + frontend)
npm run dev
# Start only backend
npm run dev:backend
# Start only frontend
npm run dev:frontend
# Start mobile app
npx turbo run dev --filter=frontend-mobile
```
### Build
```bash
# Build all apps
npm run build
# Build only frontend
npm run build:web
```
### Test
```bash
# Run all tests
npm test
# Test specific app
npm test --workspace=farma-clic-backend
npm test --workspace=farma-clic-frontend
```
## Docker Setup
Runs the full stack (backend, frontend, Redis, Postgres) with a single command.
```bash
# Copy and configure environment (optional - defaults work for local dev)
cp apps/backend/.env.example apps/backend/.env
docker compose up --build docker compose up --build
``` ```
App available at `http://localhost:4000` (frontend) and `http://localhost:3001` (backend API). App available at `http://localhost:3000`.
**First run - create an admin user:** **First run create an admin user:**
```bash ```bash
docker compose exec backend node create-admin.js docker compose exec backend node create-admin.js
# Default: admin / admin123 - change after first login # Default: admin / admin123 change after first login
``` ```
**Seed sample pharmacies:** **Seed sample pharmacies:**
@@ -168,37 +61,62 @@ docker compose exec backend node seed.js
docker compose down docker compose down
``` ```
Database is persisted in named Docker volumes (`backend_data`, `postgres_data`). To wipe: Database is persisted in a named Docker volume (`backend_data`). To wipe it:
```bash ```bash
docker compose down -v docker compose down -v
``` ```
## Manual Setup ---
## 🚀 Manual Setup Instructions
### 1. Install Redis ### 1. Install Redis
**Ubuntu/Debian:** **On Ubuntu/Debian:**
```bash ```bash
sudo apt-get update
sudo apt-get install redis-server sudo apt-get install redis-server
sudo systemctl start redis-server sudo systemctl start redis-server
sudo systemctl enable redis-server
``` ```
**macOS:** **On macOS (using Homebrew):**
```bash ```bash
brew install redis brew install redis
brew services start redis brew services start redis
``` ```
**Docker:** **On Windows:**
Download and install from: https://redis.io/download
**Using Docker:**
```bash ```bash
docker run -d -p 6379:6379 redis:alpine docker run -d -p 6379:6379 redis:alpine
``` ```
Verify: `redis-cli ping` should respond `PONG`. Verify Redis is running:
```bash
redis-cli ping
# Should respond with: PONG
```
### 2. Configure Environment (Optional) ### 2. Install Application Dependencies
Create `apps/backend/.env`: **Install backend dependencies:**
```bash
cd backend
npm install
```
**Install frontend dependencies:**
```bash
cd ../frontend
npm install
```
### 3. Configure Environment (Optional)
Create a `.env` file in the `backend` directory if you need custom Redis settings:
```env ```env
REDIS_HOST=localhost REDIS_HOST=localhost
@@ -207,157 +125,227 @@ REDIS_PASSWORD=
SESSION_SECRET=your-secret-key-here SESSION_SECRET=your-secret-key-here
``` ```
### 3. Initialize Database ### 4. Initialize Database
**Seed the database with sample pharmacies:**
```bash ```bash
# Seed sample pharmacies cd backend
npm run dev --workspace=farma-clic-backend -- run seed npm run seed
# Create admin user (default: admin / admin123)
npm run dev --workspace=farma-clic-backend -- run create-admin
``` ```
### 4. Run **Create admin user:**
```bash ```bash
npm run create-admin
```
This creates a default admin user with:
- Username: `admin`
- Password: `admin123`
⚠️ **Important**: Change the default password after first login!
You can customize credentials using environment variables:
```bash
ADMIN_USERNAME=myadmin ADMIN_PASSWORD=mypassword npm run create-admin
```
### 5. Running the Application
**Start Redis** (if not running):
```bash
redis-server
```
**Start the backend server:**
```bash
cd backend
npm start
```
The backend will run on `http://localhost:3001`
**Start the frontend development server:**
```bash
cd frontend
npm run dev npm run dev
``` ```
The frontend will run on `http://localhost:3000`
## API Endpoints **Open your browser** and navigate to `http://localhost:3000`
### Public ## 🎯 How to Use
- `GET /api/medicines/search?q=<query>` - Search medicines (CIMA API, cached in Redis)
- `GET /api/medicines/:nregistro` - Medicine details
- `GET /api/medicines/:nregistro/pharmacies` - Pharmacies selling a medicine
- `GET /api/pharmacies` - All pharmacies
### Auth ### Public Search
- `POST /api/auth/login` - Login 1. Type the name of a medicine in the search bar
2. The app will search the CIMA database in real-time
3. Click on a medicine to see which pharmacies have it
4. View prices and stock availability
### Admin Panel
1. Click the "⚙️ Admin Panel" button
2. Login with your credentials (default: `admin` / `admin123`)
3. **Pharmacies Tab**: Add, edit, or delete pharmacies
4. **Medicines Tab**: Search medicines from the CIMA database
5. **Link Medicine Tab**: Associate medicines with pharmacies and set prices/stock
## 📁 Project Structure
```
FarmaFinder/
├── docker-compose.yml # Full stack: backend + frontend + Redis
├── backend/
│ ├── Dockerfile
│ ├── server.js # Express server and API routes
│ ├── cima-service.js # CIMA API integration with Redis cache
│ ├── redis-client.js # Redis connection configuration
│ ├── seed.js # Database seeding script
│ ├── create-admin.js # Admin user creation script
│ ├── .env.example # Environment variable template
│ └── package.json
├── frontend/
│ ├── Dockerfile
│ ├── nginx.conf # Nginx config (Docker): serves SPA + proxies /api
│ ├── src/
│ │ ├── components/ # React components
│ │ │ ├── admin/ # Admin panel components
│ │ │ ├── PharmacyMap.jsx # Leaflet map (OpenStreetMap)
│ │ │ └── ...
│ │ ├── views/ # View components (Public/Admin)
│ │ ├── App.jsx # Main app component
│ │ └── main.jsx # Entry point
│ ├── index.html
│ └── package.json
└── README.md
```
## 🔌 API Endpoints
### Public API
- `GET /api/medicines/search?q=<query>` - Search medicines from CIMA API (cached in Redis)
- `GET /api/medicines/:nregistro` - Get medicine details from CIMA
- `GET /api/medicines/:nregistro/pharmacies` - Get pharmacies selling a medicine
- `GET /api/pharmacies` - Get all pharmacies
### Authentication API
- `POST /api/auth/login` - Login (requires username and password)
- `POST /api/auth/logout` - Logout - `POST /api/auth/logout` - Logout
- `GET /api/auth/check` - Check auth status - `GET /api/auth/check` - Check authentication status
### Admin (requires authentication) ### Admin API (All require authentication)
- `POST /api/admin/pharmacies` - Add pharmacy - `POST /api/admin/pharmacies` - Add a new pharmacy
- `PUT /api/admin/pharmacies/:id` - Update pharmacy - `PUT /api/admin/pharmacies/:id` - Update a pharmacy
- `DELETE /api/admin/pharmacies/:id` - Delete pharmacy - `DELETE /api/admin/pharmacies/:id` - Delete a pharmacy
- `GET /api/admin/medicines?q=<query>` - Search medicines - `GET /api/admin/medicines?q=<query>` - Search medicines from CIMA (for admin)
- `GET /api/admin/pharmacies/:id/medicines` - Linked medicines - `GET /api/admin/pharmacies/:id/medicines` - Get medicines linked to a pharmacy
- `POST /api/admin/pharmacy-medicines` - Link medicine to pharmacy - `POST /api/admin/pharmacy-medicines` - Link medicine to pharmacy
- `PUT /api/admin/pharmacy-medicines/:id` - Update price/stock - `PUT /api/admin/pharmacy-medicines/:id` - Update price/stock
- `DELETE /api/admin/pharmacy-medicines/:id` - Remove link - `DELETE /api/admin/pharmacy-medicines/:id` - Remove medicine from pharmacy
## Database Schema ## 💾 Database Schema
### SQLite Tables ### SQLite Tables
**pharmacies**: `id`, `name`, `address`, `phone`, `latitude`, `longitude` **pharmacies**
- `id`: Integer (Primary Key)
- `name`: Text (Pharmacy name)
- `address`: Text (Full address)
- `phone`: Text (Contact phone)
- `latitude`: Real (GPS coordinate)
- `longitude`: Real (GPS coordinate)
**pharmacy_medicines**: `id`, `pharmacy_id`, `medicine_nregistro`, `medicine_name`, `price`, `stock` **pharmacy_medicines**
- `id`: Integer (Primary Key)
- `pharmacy_id`: Integer (Foreign Key → pharmacies.id)
- `medicine_nregistro`: Text (CIMA medicine registration number)
- `medicine_name`: Text (Cached medicine name)
- `price`: Real (Price in EUR)
- `stock`: Integer (Available units)
**users**: `id`, `username`, `password_hash`, `created_at` **users**
- `id`: Integer (Primary Key)
- `username`: Text (Unique)
- `password_hash`: Text (Bcrypt hashed)
- `created_at`: DateTime
### Redis Cache ### Redis Cache Structure
- `medicines:search:{query}` - Search results (TTL: 1h) - `medicines:search:{query}` - Search results (TTL: 1 hour)
- `medicine:{nregistro}` - Medicine details (TTL: 24h) - `medicine:{nregistro}` - Medicine details (TTL: 24 hours)
## Mobile App Setup ## 🔧 Architecture Changes
### Prerequisites ### Migration from Local Database to CIMA API
- Node.js v20+ The application now uses the **CIMA (Centro de Información online de Medicamentos)** API as the source of truth for medicine data, with Redis caching for performance:
- Expo CLI: `npm install -g expo-cli`
- EAS CLI: `npm install -g eas-cli`
- iOS: Xcode + CocoaPods (Mac only)
- Android: Android Studio + SDK
### Development **Benefits:**
- ✅ Always up-to-date medicine information
- ✅ Official data from Spanish health authorities
- ✅ Reduced database maintenance
- ✅ Fast responses thanks to Redis cache
- ✅ Fallback to stale cache if API is down
```bash **Changes:**
# Install dependencies (already done via npm install at root) - Medicines are no longer stored locally in SQLite
- Medicine searches query the CIMA API
- Results are cached in Redis for performance
- `pharmacy_medicines` now uses `medicine_nregistro` (CIMA registration number) instead of local `medicine_id`
# Start mobile dev server ## 🐛 Troubleshooting
npx turbo run dev --filter=frontend-mobile
# Scan QR code with Expo Go app
```
### Native Features
| Feature | Implementation |
|---------|---------------|
| Barcode Scanner | `expo-camera` with `CameraView` |
| Push Notifications | `expo-notifications` |
| Biometrics | `expo-local-authentication` |
| Maps | `react-native-maps` |
| Secure Storage | `expo-secure-store` |
### EAS Build
```bash
cd apps/frontend-mobile
# Development build
eas build --profile development --platform ios
eas build --profile development --platform android
# Production build
eas build --profile production --platform android
eas build --profile production --platform ios
# Submit to stores
eas submit --profile production --platform android
eas submit --profile production --platform ios
```
### Environment Configuration
```typescript
// apps/frontend-mobile/constants/config.ts
const ENV = {
development: { API_BASE_URL: 'http://localhost:3001/api' },
production: { API_BASE_URL: 'https://your-production-api.com/api' },
};
```
## Troubleshooting
### Redis Connection Issues ### Redis Connection Issues
If you see "Redis Client Error":
```bash ```bash
redis-cli ping # Should respond: PONG # Check if Redis is running
redis-server # Start if not running redis-cli ping
redis-cli FLUSHALL # Clear cache
# Start Redis if needed
redis-server
``` ```
### CIMA API Timeout ### CIMA API Timeout
- Check internet connection
- CIMA API may be temporarily unavailable
- App falls back to cached data
### Database Reset If medicine searches are slow or failing:
- Check your internet connection
- The CIMA API may be temporarily unavailable
- The app will use cached data if available
### Database Issues
Reset the database:
```bash ```bash
cd apps/backend cd backend
rm database.sqlite rm database.sqlite
npm run seed npm run seed
npm run create-admin npm run create-admin
``` ```
### Turborepo Cache Issues ## 📝 Development
**Backend development with auto-reload:**
```bash ```bash
npx turbo clean # Clear Turbo cache cd backend
rm -rf node_modules # Full reset npm run dev
npm install
``` ```
## External Resources **Frontend development:**
```bash
cd frontend
npm run dev
```
- [CIMA API](https://cima.aemps.es/) **Clear Redis cache:**
- [Turborepo](https://turbo.build/repo) ```bash
- [Redis](https://redis.io/documentation) redis-cli FLUSHALL
- [React](https://react.dev) ```
- [Express](https://expressjs.com)
- [Expo](https://docs.expo.dev)
## License ## 🌐 External Resources
- **CIMA API**: https://cima.aemps.es/
- **Redis Documentation**: https://redis.io/documentation
- **React Documentation**: https://react.dev
- **Express Documentation**: https://expressjs.com
## 📄 License
ISC ISC
+101
View File
@@ -0,0 +1,101 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PlanningModeManager">
<option name="approvalStates">
<map>
<entry key="20260630-090134-081bb55a-1053-43ab-950d-e13c6adcdde9" value="true" />
<entry key="20260630-090415-b044c503-d35a-4112-8a57-02da5e73f9d9" value="true" />
<entry key="20260630-090515-c7bb6ec8-96fa-4399-98d4-5e18af72de07" value="true" />
<entry key="20260630-090813-d485fedc-ebae-4bad-85e8-8c07f2842ed0" value="true" />
</map>
</option>
</component>
</project>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>
+2
View File
@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep
+68
View File
@@ -0,0 +1,68 @@
apply plugin: 'com.android.application'
def keystorePropertiesFile = rootProject.file('keystore.properties')
def keystoreProperties = new Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
namespace "net.hacecalor.farmaclic"
compileSdk rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "net.hacecalor.farmaclic"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
signingConfigs {
release {
if (keystorePropertiesFile.exists()) {
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
}
}
}
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
if (keystorePropertiesFile.exists()) {
signingConfig signingConfigs.release
}
}
}
}
dependencies {
implementation fileTree(include: ['*.jar', '*.aar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}
+22
View File
@@ -0,0 +1,22 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-mlkit-barcode-scanning')
implementation project(':capacitor-app')
implementation project(':capacitor-splash-screen')
implementation project(':capacitor-status-bar')
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}
+45
View File
@@ -0,0 +1,45 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
# --- Capacitor 6 ---
# Plugins are discovered + invoked by reflection from the JS bridge.
-keep class com.getcapacitor.** { *; }
-keep @com.getcapacitor.annotation.CapacitorPlugin class * { *; }
-keepclassmembers class * extends com.getcapacitor.Plugin {
@com.getcapacitor.PluginMethod public *;
}
-keepclassmembers @com.getcapacitor.annotation.CapacitorPlugin class * {
@com.getcapacitor.PluginMethod public *;
}
# --- Cordova compatibility layer (capacitor-cordova-android-plugins) ---
-keep class org.apache.cordova.** { *; }
-keep public class * extends org.apache.cordova.CordovaPlugin
# --- WebView JS interface ---
-keepclassmembers class * {
@android.webkit.JavascriptInterface <methods>;
}
# Helpful stack traces from release builds.
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
@@ -0,0 +1,26 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}
+50
View File
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="barcode_ui" />
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.CAMERA" />
</manifest>
@@ -0,0 +1,5 @@
package net.hacecalor.farmaclic;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">FarmaClic</string>
<string name="title_activity_main">FarmaClic</string>
<string name="package_name">net.hacecalor.farmaclic</string>
<string name="custom_url_scheme">net.hacecalor.farmaclic</string>
</resources>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
@@ -0,0 +1,18 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import org.junit.Test;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}
+29
View File
@@ -0,0 +1,29 @@
// 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:9.2.1'
classpath 'com.google.gms:google-services:4.4.4'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
apply from: "variables.gradle"
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
+15
View File
@@ -0,0 +1,15 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-mlkit-barcode-scanning'
project(':capacitor-mlkit-barcode-scanning').projectDir = new File('../node_modules/@capacitor-mlkit/barcode-scanning/android')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
include ':capacitor-splash-screen'
project(':capacitor-splash-screen').projectDir = new File('../node_modules/@capacitor/splash-screen/android')
include ':capacitor-status-bar'
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
+27
View File
@@ -0,0 +1,27 @@
# 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.
org.gradle.jvmargs=-Xmx1536m
# 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
android.defaults.buildfeatures.resvalues=false
android.uniquePackageNames=false
android.dependency.useConstraints=true
android.r8.strictFullModeForKeepRules=false
android.newDsl=true
@@ -0,0 +1,13 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect
toolchainVendor=JETBRAINS
toolchainVersion=21
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 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
#
##############################################################################
#
# gradlew 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 gradlew
#
# 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/3d91ce3b8caaf77ad09f381f43615b715b53f72c/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
# 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" )
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, 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" \
-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" "$@"
+82
View File
@@ -0,0 +1,82 @@
@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 gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
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
"%COMSPEC%" /c exit 1
: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
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+8
View File
@@ -0,0 +1,8 @@
plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
}
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'
+16
View File
@@ -0,0 +1,16 @@
ext {
minSdkVersion = 24
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
androidxAppCompatVersion = '1.7.1'
androidxCoordinatorLayoutVersion = '1.3.0'
androidxCoreVersion = '1.17.0'
androidxFragmentVersion = '1.8.9'
coreSplashScreenVersion = '1.2.0'
androidxWebkitVersion = '1.14.0'
junitVersion = '4.13.2'
androidxJunitVersion = '1.3.0'
androidxEspressoCoreVersion = '3.7.0'
cordovaAndroidVersion = '14.0.1'
}
-9
View File
@@ -1,9 +0,0 @@
{
"expo": {
"extra": {
"eas": {
"projectId": "ffaa53eb-cd84-4686-bad7-0a2c3b7da73b"
}
}
}
}
-9
View File
@@ -1,9 +0,0 @@
FROM node:24-alpine
WORKDIR /app
COPY apps/backend/package*.json ./
RUN apk add --no-cache python3 make g++ && npm ci --omit=dev
COPY apps/backend/ .
COPY apps/API/ /API/
RUN mkdir -p /app/data
EXPOSE 3001
CMD ["node", "server.js"]
@@ -1,5 +0,0 @@
{
"enabledPlugins": {
"expo@claude-plugins-official": true
}
}
-8
View File
@@ -1,8 +0,0 @@
# FarmaFinder Mobile - Environment Variables
# API Configuration
# Change this to your production API URL
EXPO_PUBLIC_API_URL=http://localhost:3001/api
# For production builds, update this to:
# EXPO_PUBLIC_API_URL=https://api.yourdomain.com/api
-42
View File
@@ -1,42 +0,0 @@
# Expo
.expo/
dist/
web-build/
# Native
ios/
android/
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# env files
.env
.env.local
.env.production
# EAS
eas-cli.json
# Dependencies
node_modules/
# OS
.DS_Store
Thumbs.db
# Build artifacts
*.apk
*.aab
*.ipa
File diff suppressed because it is too large Load Diff
-9
View File
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/frontend-mobile.iml" filepath="$PROJECT_DIR$/.idea/frontend-mobile.iml" />
</modules>
</component>
</project>
-7
View File
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/../.." vcs="Git" />
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>
@@ -1 +0,0 @@
{"pid":1390854,"startedAt":1783459832497}
-3
View File
@@ -1,3 +0,0 @@
# Expo HAS CHANGED
Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code.
-20
View File
@@ -1,20 +0,0 @@
import { StatusBar } from 'expo-status-bar';
import { StyleSheet, Text, View } from 'react-native';
export default function App() {
return (
<View style={styles.container}>
<Text>Open up App.tsx to start working on your app!</Text>
<StatusBar style="auto" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
});
-1
View File
@@ -1 +0,0 @@
@AGENTS.md
-21
View File
@@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-59
View File
@@ -1,59 +0,0 @@
{
"expo": {
"name": "FarmaFinder",
"slug": "farmafinder",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#007AFF"
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.farmafinder.app",
"config": {
"usesNonExemptEncryption": false
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#007AFF"
},
"package": "com.farmafinder.app",
"googleServicesFile": "./google-services.json",
"config": {
"googleMaps": {
"apiKey": ""
}
}
},
"plugins": [
"expo-router",
["expo-camera", {"cameraPermission": "Allow FarmaFinder to access your camera for scanning barcodes"}],
["expo-notifications", {"icon": "./assets/notification-icon.png", "color": "#007AFF"}],
[
"react-native-maps",
{
"locationAlwaysAndWhenInUsePermission": "Allow FarmaFinder to use your location to find nearby pharmacies."
}
],
[
"expo-location",
{
"locationAlwaysAndWhenInUsePermission": "Allow FarmaFinder to use your location to find nearby pharmacies."
}
]
],
"scheme": "farmafinder",
"extra": {
"eas": {
"projectId": "9a424f20-1073-4477-93e1-6b302a1ae389"
}
}
}
}
-128
View File
@@ -1,128 +0,0 @@
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { View, StyleSheet, useWindowDimensions } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useThemeContext } from '../../components/ThemeProvider';
import { shadows } from '../../constants/theme';
const TABLET_MIN_WIDTH = 768;
// Standard Android navigation bar heights in dp
const ANDROID_NAV_BAR_HEIGHT = 48;
function ScanIcon({ size }: { size: number }) {
return (
<View style={styles.fabContainer}>
<View style={[styles.fab, shadows.scanButton]}>
<Ionicons name="scan" size={size} color="#ffffff" />
</View>
</View>
);
}
export default function TabLayout() {
const insets = useSafeAreaInsets();
const { width } = useWindowDimensions();
const isTablet = width >= TABLET_MIN_WIDTH;
const { colors } = useThemeContext();
// Use safe area insets if available, otherwise use standard Android nav bar height
const bottomPadding = insets.bottom > 0 ? insets.bottom : ANDROID_NAV_BAR_HEIGHT;
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.textSecondary,
tabBarStyle: {
...(isTablet ? styles.tabBarTablet : {}),
backgroundColor: colors.card,
borderTopColor: colors.border,
paddingTop: isTablet ? 10 : 8,
height: (isTablet ? 64 : 60) + bottomPadding,
paddingBottom: bottomPadding,
...(isTablet ? { paddingHorizontal: 24 } : {}),
},
tabBarLabelStyle: {
fontSize: isTablet ? 13 : 11,
fontWeight: '500',
},
headerStyle: {
backgroundColor: colors.card,
},
headerTintColor: colors.primary,
headerTitleStyle: {
color: colors.text,
fontWeight: '600',
fontSize: isTablet ? 20 : 17,
},
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Inicio',
tabBarIcon: ({ color, size }) => (
<Ionicons name="home" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="search"
options={{
title: 'Buscar',
tabBarIcon: ({ color, size }) => (
<Ionicons name="search" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="scan"
options={{
title: 'Escanear',
tabBarIcon: ({ color, size }) => <ScanIcon color={color} size={size} />,
tabBarLabel: () => null,
}}
/>
<Tabs.Screen
name="alerts"
options={{
title: 'Avisos',
tabBarIcon: ({ color, size }) => (
<Ionicons name="notifications" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="map"
options={{
href: null,
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Perfil',
tabBarIcon: ({ color, size }) => (
<Ionicons name="person" size={size} color={color} />
),
}}
/>
</Tabs>
);
}
const styles = StyleSheet.create({
fabContainer: {
position: 'absolute',
top: -16,
alignItems: 'center',
},
fab: {
width: 52,
height: 52,
borderRadius: 26,
backgroundColor: '#2b5bb5',
alignItems: 'center',
justifyContent: 'center',
},
});
-301
View File
@@ -1,301 +0,0 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Alert, useWindowDimensions } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useThemeContext } from '../../components/ThemeProvider';
import { spacing, borderRadius, shadows } from '../../constants/theme';
import { LoadingSpinner } from '../../components/LoadingSpinner';
import api from '../../services/api';
const TABLET_MIN_WIDTH = 768;
interface NotificationItem {
scope: string;
id: number;
medicine_name?: string;
medicine_nregistro?: string;
pharmacy_name?: string;
pharmacy_id?: number;
pharmacy_address?: string;
created_at?: string;
}
export default function AlertsScreen() {
const { width } = useWindowDimensions();
const isTablet = width >= TABLET_MIN_WIDTH;
const { colors } = useThemeContext();
const [items, setItems] = useState<NotificationItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
useEffect(() => {
loadNotifications();
}, []);
async function loadNotifications() {
setIsLoading(true);
setError(null);
try {
const res = await api.get('/notifications/mine');
const data = res.data;
const merged = [
...(data.pharmacy || []),
...(data.global || []),
].sort((a: NotificationItem, b: NotificationItem) =>
(b.created_at || '').localeCompare(a.created_at || '')
);
setItems(merged);
} catch (err: any) {
setError(err.message || 'No se pudieron cargar las notificaciones');
} finally {
setIsLoading(false);
}
}
async function handleDelete(item: NotificationItem) {
const key = `${item.scope}:${item.id}`;
Alert.alert(
'Eliminar notificación',
`¿Eliminar la notificación de ${item.medicine_name || item.medicine_nregistro}?`,
[
{ text: 'Cancelar', style: 'cancel' },
{
text: 'Eliminar',
style: 'destructive',
onPress: async () => {
setDeletingId(key);
try {
await api.delete('/notifications/mine', {
data: { scope: item.scope, id: item.id },
});
setItems(prev => prev.filter(i => !(i.scope === item.scope && i.id === item.id)));
} catch (err: any) {
Alert.alert('Error', err.message || 'No se pudo eliminar');
} finally {
setDeletingId(null);
}
},
},
]
);
}
function renderItem({ item }: { item: NotificationItem }) {
const key = `${item.scope}:${item.id}`;
return (
<View style={[styles.item, { backgroundColor: colors.card }]}>
<View style={styles.itemContent}>
<Text style={[styles.itemName, { color: colors.text }]} numberOfLines={2}>
{item.medicine_name || item.medicine_nregistro}
</Text>
<View style={styles.itemMeta}>
<View style={[styles.chip, { backgroundColor: colors.primaryContainer }]}>
<Ionicons
name={item.scope === 'pharmacy' ? 'medical' : 'globe'}
size={12}
color={colors.primary}
/>
<Text style={[styles.chipText, { color: colors.onPrimaryContainer }]}>
{item.scope === 'pharmacy'
? item.pharmacy_name || `Farmacia #${item.pharmacy_id}`
: 'Cualquier farmacia'}
</Text>
</View>
{item.pharmacy_address && (
<Text style={[styles.itemAddress, { color: colors.textSecondary }]} numberOfLines={1}>
{item.pharmacy_address}
</Text>
)}
</View>
</View>
<TouchableOpacity
style={[styles.deleteButton, { backgroundColor: colors.dangerContainer }]}
onPress={() => handleDelete(item)}
disabled={deletingId === key}
>
{deletingId === key ? (
<Ionicons name="hourglass" size={18} color={colors.danger} />
) : (
<Ionicons name="trash-outline" size={18} color={colors.danger} />
)}
</TouchableOpacity>
</View>
);
}
if (isLoading) {
return <LoadingSpinner message="Cargando notificaciones..." />;
}
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<View style={[styles.header, isTablet && styles.headerTablet]}>
<Text style={[styles.title, isTablet && styles.titleTablet, { color: colors.text }]}>Notificaciones Guardadas</Text>
<Text style={[styles.subtitle, isTablet && styles.subtitleTablet, { color: colors.textSecondary }]}>
Recibe avisos cuando medicamentos sin stock se repongan
</Text>
</View>
{error && (
<View style={[styles.errorContainer, isTablet && styles.errorContainerTablet, { backgroundColor: colors.dangerContainer }]}>
<Text style={[styles.errorText, { color: colors.danger }]}>{error}</Text>
<TouchableOpacity onPress={loadNotifications} style={styles.retryButton}>
<Text style={[styles.retryText, { color: colors.primary }]}>Reintentar</Text>
</TouchableOpacity>
</View>
)}
{!error && items.length === 0 && (
<View style={styles.emptyContainer}>
<Ionicons name="notifications-off-outline" size={isTablet ? 80 : 64} color={colors.border} />
<Text style={[styles.emptyTitle, isTablet && styles.emptyTitleTablet, { color: colors.text }]}>Sin notificaciones</Text>
<Text style={[styles.emptyText, isTablet && styles.emptyTextTablet, { color: colors.textSecondary }]}>
Toca la campana en una farmacia sin stock para recibir notificaciones cuando se reponga.
</Text>
</View>
)}
{!error && items.length > 0 && (
<FlatList
data={items}
keyExtractor={(item) => `${item.scope}:${item.id}`}
renderItem={renderItem}
contentContainerStyle={[styles.list, isTablet && styles.listTablet]}
showsVerticalScrollIndicator={false}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
paddingHorizontal: spacing.lg,
paddingTop: spacing.lg,
paddingBottom: spacing.md,
},
headerTablet: {
maxWidth: 700,
alignSelf: 'center',
width: '100%',
},
title: {
fontSize: 22,
fontWeight: 'bold',
},
titleTablet: {
fontSize: 28,
},
subtitle: {
fontSize: 14,
marginTop: spacing.xs,
lineHeight: 20,
},
subtitleTablet: {
fontSize: 16,
lineHeight: 24,
},
list: {
paddingHorizontal: spacing.lg,
paddingBottom: spacing.xl,
gap: spacing.sm,
},
listTablet: {
maxWidth: 700,
alignSelf: 'center',
width: '100%',
},
item: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: borderRadius.lg,
padding: spacing.md,
...shadows.card,
},
itemContent: {
flex: 1,
gap: spacing.xs,
},
itemName: {
fontSize: 16,
fontWeight: '600',
lineHeight: 22,
},
itemMeta: {
gap: spacing.xs,
},
chip: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
borderRadius: borderRadius.full,
paddingHorizontal: spacing.sm,
paddingVertical: 3,
alignSelf: 'flex-start',
},
chipText: {
fontSize: 12,
fontWeight: '600',
},
itemAddress: {
fontSize: 12,
},
deleteButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginLeft: spacing.sm,
},
errorContainer: {
margin: spacing.lg,
padding: spacing.md,
borderRadius: borderRadius.lg,
alignItems: 'center',
gap: spacing.sm,
},
errorContainerTablet: {
maxWidth: 700,
alignSelf: 'center',
},
errorText: {
fontSize: 14,
textAlign: 'center',
},
retryButton: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
},
retryText: {
fontSize: 14,
fontWeight: '600',
},
emptyContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: spacing.xl,
gap: spacing.md,
},
emptyTitle: {
fontSize: 18,
fontWeight: '600',
},
emptyTitleTablet: {
fontSize: 24,
},
emptyText: {
fontSize: 14,
textAlign: 'center',
lineHeight: 20,
},
emptyTextTablet: {
fontSize: 16,
maxWidth: 400,
lineHeight: 24,
},
});
-163
View File
@@ -1,163 +0,0 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Image, useWindowDimensions } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useThemeContext } from '../../components/ThemeProvider';
import { spacing, borderRadius, shadows } from '../../constants/theme';
const TABLET_MIN_WIDTH = 768;
export default function HomeScreen() {
const router = useRouter();
const { width } = useWindowDimensions();
const isTablet = width >= TABLET_MIN_WIDTH;
const { colors } = useThemeContext();
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<View style={[styles.hero, isTablet && styles.heroTablet]}>
<Image
source={require('../../assets/farmaclic_logo.png')}
style={[styles.logo, isTablet && styles.logoTablet]}
resizeMode="contain"
/>
<Text style={[styles.brandName, isTablet && styles.brandNameTablet, { color: colors.text }]}>FarmaClic</Text>
<Text style={[styles.description, isTablet && styles.descriptionTablet, { color: colors.textSecondary }]}>
Encuentra tus medicamentos en farmacias cercanas
</Text>
</View>
<View style={[styles.cards, isTablet && styles.cardsTablet]}>
<TouchableOpacity
style={[styles.card, isTablet && styles.cardTablet, { backgroundColor: colors.primaryContainer }]}
activeOpacity={0.85}
onPress={() => router.push('/(tabs)/search')}
>
<View style={[styles.cardIcon, styles.cardIconSearch]}>
<Ionicons name="search" size={isTablet ? 28 : 24} color={colors.onPrimaryContainer} />
</View>
<View style={styles.cardContent}>
<Text style={[styles.cardLabel, isTablet && styles.cardLabelTablet, { color: colors.onPrimaryContainer }]}>Buscar Medicamento</Text>
<Ionicons name="chevron-forward" size={20} color={colors.onPrimaryContainer} style={{ opacity: 0.7 }} />
</View>
</TouchableOpacity>
<TouchableOpacity
style={[styles.card, styles.cardScan, isTablet && styles.cardTablet]}
activeOpacity={0.85}
onPress={() => router.push('/scanner')}
>
<View style={[styles.cardIcon, styles.cardIconScan]}>
<Ionicons name="scan" size={isTablet ? 28 : 24} color="#ffffff" />
</View>
<View style={styles.cardContent}>
<Text style={[styles.cardLabel, styles.cardLabelScan, isTablet && styles.cardLabelTablet]}>Escanear TSI</Text>
<Ionicons name="chevron-forward" size={20} color="#ffffff" style={{ opacity: 0.7 }} />
</View>
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: spacing.lg,
gap: spacing.lg,
},
hero: {
alignItems: 'center',
gap: spacing.sm,
marginBottom: spacing.md,
},
heroTablet: {
marginBottom: spacing.xl,
},
logo: {
width: 120,
height: 120,
marginBottom: spacing.xs,
},
logoTablet: {
width: 160,
height: 160,
},
brandName: {
fontSize: 28,
fontWeight: 'bold',
letterSpacing: -0.5,
},
brandNameTablet: {
fontSize: 36,
},
description: {
fontSize: 16,
textAlign: 'center',
maxWidth: 260,
lineHeight: 24,
},
descriptionTablet: {
fontSize: 18,
maxWidth: 400,
lineHeight: 28,
},
cards: {
width: '100%',
maxWidth: 320,
gap: spacing.md,
},
cardsTablet: {
maxWidth: 480,
},
card: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
borderRadius: borderRadius.lg,
padding: spacing.md,
minHeight: 64,
...shadows.card,
},
cardTablet: {
padding: spacing.lg,
minHeight: 72,
},
cardScan: {
backgroundColor: '#2b5bb5',
},
cardIcon: {
width: 48,
height: 48,
borderRadius: borderRadius.md,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
},
cardIconSearch: {
backgroundColor: 'rgba(255, 255, 255, 0.3)',
},
cardIconScan: {
backgroundColor: 'rgba(255, 255, 255, 0.2)',
},
cardContent: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
cardLabel: {
fontSize: 18,
fontWeight: '700',
lineHeight: 24,
},
cardLabelTablet: {
fontSize: 20,
},
cardLabelScan: {
color: '#ffffff',
},
});
-107
View File
@@ -1,107 +0,0 @@
import React, { useEffect, useState } from 'react';
import { View, StyleSheet, Text } from 'react-native';
import MapView, { Marker } from 'react-native-maps';
import { useRouter } from 'expo-router';
import { getPharmacies } from '../../services/pharmacies';
import { LoadingSpinner } from '../../components/LoadingSpinner';
import { useThemeContext } from '../../components/ThemeProvider';
import { spacing, borderRadius } from '../../constants/theme';
import { Pharmacy } from '../../types';
export default function MapScreen() {
const router = useRouter();
const { colors } = useThemeContext();
const [pharmacies, setPharmacies] = useState<Pharmacy[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [region, setRegion] = useState({
latitude: 40.4168,
longitude: -3.7038,
latitudeDelta: 0.0922,
longitudeDelta: 0.0421,
});
useEffect(() => {
const fetchPharmacies = async () => {
try {
const data = await getPharmacies();
setPharmacies(data);
if (data.length > 0) {
setRegion({
latitude: data[0].latitude,
longitude: data[0].longitude,
latitudeDelta: 0.0922,
longitudeDelta: 0.0421,
});
}
} catch (error) {
console.error('Error fetching pharmacies:', error);
} finally {
setIsLoading(false);
}
};
fetchPharmacies();
}, []);
if (isLoading) {
return <LoadingSpinner message="Cargando farmacias..." />;
}
return (
<View style={styles.container}>
<MapView
style={styles.map}
region={region}
onRegionChangeComplete={setRegion}
showsUserLocation={true}
showsMyLocationButton={true}
>
{pharmacies.map((pharmacy) => (
<Marker
key={pharmacy.id}
coordinate={{
latitude: pharmacy.latitude,
longitude: pharmacy.longitude,
}}
title={pharmacy.name}
description={pharmacy.address}
onCalloutPress={() => router.push(`/pharmacy/${pharmacy.id}`)}
/>
))}
</MapView>
<View style={[styles.legend, { backgroundColor: colors.card }]}>
<Text style={[styles.legendText, { color: colors.text }]}>
{pharmacies.length} farmacias en el mapa
</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
map: {
flex: 1,
},
legend: {
position: 'absolute',
bottom: spacing.lg,
left: spacing.md,
right: spacing.md,
borderRadius: borderRadius.lg,
padding: spacing.sm + 4,
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.08,
shadowRadius: 20,
elevation: 4,
},
legendText: {
fontSize: 14,
},
});
File diff suppressed because it is too large Load Diff
-347
View File
@@ -1,347 +0,0 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, TextInput, Alert, useWindowDimensions, ScrollView } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import * as ImagePicker from 'expo-image-picker';
import { useThemeContext } from '../../components/ThemeProvider';
import { spacing, borderRadius, shadows } from '../../constants/theme';
const TABLET_MIN_WIDTH = 768;
export default function ScanTabScreen() {
const router = useRouter();
const { width } = useWindowDimensions();
const isTablet = width >= TABLET_MIN_WIDTH;
const { colors } = useThemeContext();
const [manualNumber, setManualNumber] = useState('');
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const handleTakePhoto = async () => {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') {
Alert.alert(
'Permiso requerido',
'Necesitamos acceso a la cámara para tomar fotos del dispositivo.'
);
return;
}
const result = await ImagePicker.launchCameraAsync({
mediaTypes: ['images'],
allowsEditing: true,
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
setSelectedImage(result.assets[0].uri);
}
};
const handleSelectFromGallery = async () => {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
Alert.alert(
'Permiso requerido',
'Necesitamos acceso a la galería para seleccionar fotos.'
);
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsEditing: true,
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
setSelectedImage(result.assets[0].uri);
}
};
const handleManualSubmit = () => {
const trimmed = manualNumber.trim();
if (trimmed.length === 0) {
Alert.alert('Campo requerido', 'Por favor, introduce el número de la tarjeta.');
return;
}
router.push(`/medicine/${trimmed}`);
};
return (
<ScrollView
style={[styles.scrollContainer, { backgroundColor: colors.background }]}
contentContainerStyle={styles.scrollContent}
>
<View style={[styles.content, isTablet && styles.contentTablet]}>
<View style={[styles.iconContainer, isTablet && styles.iconContainerTablet, { backgroundColor: colors.primaryContainer }]}>
<Ionicons name="scan" size={isTablet ? 80 : 64} color={colors.scanButton} />
</View>
<Text style={[styles.title, isTablet && styles.titleTablet, { color: colors.text }]}>Escanear TSI</Text>
<Text style={[styles.description, isTablet && styles.descriptionTablet, { color: colors.textSecondary }]}>
Escanea el código de barras de tu tarjeta sanitaria para encontrar tus medicamentos
</Text>
<TouchableOpacity
style={[styles.primaryButton, shadows.scanButton, isTablet && styles.primaryButtonTablet]}
activeOpacity={0.85}
onPress={() => router.push('/scanner')}
>
<Ionicons name="scan" size={isTablet ? 28 : 24} color="#ffffff" />
<Text style={[styles.primaryButtonText, isTablet && styles.primaryButtonTextTablet]}>
Iniciar escaneo
</Text>
</TouchableOpacity>
{/* Separator */}
<View style={styles.separator}>
<View style={[styles.separatorLine, { backgroundColor: colors.border }]} />
<Text style={[styles.separatorText, { color: colors.textSecondary }]}>o</Text>
<View style={[styles.separatorLine, { backgroundColor: colors.border }]} />
</View>
{/* Option 2: Upload device photo */}
<View style={[styles.optionCard, isTablet && styles.optionCardTablet, { backgroundColor: colors.card, borderColor: colors.border }]}>
<Ionicons name="camera" size={24} color={colors.primary} />
<View style={styles.optionTextContainer}>
<Text style={[styles.optionTitle, isTablet && styles.optionTitleTablet, { color: colors.text }]}>
Subir foto del dispositivo
</Text>
<Text style={[styles.optionDescription, isTablet && styles.optionDescriptionTablet, { color: colors.textSecondary }]}>
Toma o selecciona una foto del dispositivo sanitario
</Text>
</View>
</View>
<View style={styles.photoButtonsRow}>
<TouchableOpacity
style={[styles.photoButton, isTablet && styles.photoButtonTablet, { backgroundColor: colors.primaryContainer, borderColor: colors.primary }]}
activeOpacity={0.7}
onPress={handleTakePhoto}
>
<Ionicons name="camera-outline" size={20} color={colors.primary} />
<Text style={[styles.photoButtonText, { color: colors.primary }]}>Tomar foto</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.photoButton, isTablet && styles.photoButtonTablet, { backgroundColor: colors.primaryContainer, borderColor: colors.primary }]}
activeOpacity={0.7}
onPress={handleSelectFromGallery}
>
<Ionicons name="images-outline" size={20} color={colors.primary} />
<Text style={[styles.photoButtonText, { color: colors.primary }]}>Galería</Text>
</TouchableOpacity>
</View>
{selectedImage && (
<View style={styles.imagePreviewContainer}>
<Ionicons name="checkmark-circle" size={20} color={colors.success} />
<Text style={[styles.imagePreviewText, { color: colors.success }]}>Foto seleccionada correctamente</Text>
</View>
)}
{/* Option 3: Enter card number manually */}
<View style={[styles.optionCard, isTablet && styles.optionCardTablet, { backgroundColor: colors.card, borderColor: colors.border }]}>
<Ionicons name="keypad" size={24} color={colors.primary} />
<View style={styles.optionTextContainer}>
<Text style={[styles.optionTitle, isTablet && styles.optionTitleTablet, { color: colors.text }]}>
Introducir número manualmente
</Text>
<Text style={[styles.optionDescription, isTablet && styles.optionDescriptionTablet, { color: colors.textSecondary }]}>
Escribe el número de tu tarjeta sanitaria
</Text>
</View>
</View>
<View style={styles.manualInputRow}>
<TextInput
style={[styles.manualInput, isTablet && styles.manualInputTablet, { backgroundColor: colors.card, borderColor: colors.border, color: colors.text }]}
placeholder="Introduce el número de tarjeta"
placeholderTextColor={colors.textSecondary}
value={manualNumber}
onChangeText={setManualNumber}
keyboardType="default"
autoCapitalize="none"
autoCorrect={false}
returnKeyType="search"
onSubmitEditing={handleManualSubmit}
/>
<TouchableOpacity
style={[styles.manualSubmitButton, isTablet && styles.manualSubmitButtonTablet]}
activeOpacity={0.7}
onPress={handleManualSubmit}
>
<Ionicons name="arrow-forward" size={20} color="#ffffff" />
</TouchableOpacity>
</View>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
scrollContainer: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
paddingVertical: spacing.xl,
},
content: {
alignItems: 'center',
paddingHorizontal: spacing.xl,
gap: spacing.md,
},
contentTablet: {
gap: spacing.lg,
},
iconContainer: {
width: 100,
height: 100,
borderRadius: 50,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.sm,
},
iconContainerTablet: {
width: 140,
height: 140,
borderRadius: 70,
},
title: {
fontSize: 22,
fontWeight: 'bold',
},
titleTablet: {
fontSize: 28,
},
description: {
fontSize: 15,
textAlign: 'center',
lineHeight: 22,
maxWidth: 280,
},
descriptionTablet: {
fontSize: 17,
maxWidth: 420,
lineHeight: 26,
},
primaryButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
backgroundColor: '#2b5bb5',
borderRadius: borderRadius.lg,
paddingVertical: spacing.md,
paddingHorizontal: spacing.xl,
marginTop: spacing.sm,
},
primaryButtonTablet: {
paddingVertical: spacing.lg,
paddingHorizontal: spacing.xl * 1.5,
},
primaryButtonText: {
fontSize: 17,
fontWeight: '600',
color: '#ffffff',
},
primaryButtonTextTablet: {
fontSize: 20,
},
separator: {
flexDirection: 'row',
alignItems: 'center',
width: '100%',
marginVertical: spacing.sm,
},
separatorLine: {
flex: 1,
height: 1,
},
separatorText: {
marginHorizontal: spacing.md,
fontSize: 14,
},
optionCard: {
flexDirection: 'row',
alignItems: 'center',
width: '100%',
borderRadius: borderRadius.lg,
padding: spacing.md,
gap: spacing.md,
borderWidth: 1,
},
optionCardTablet: {
padding: spacing.lg,
},
optionTextContainer: {
flex: 1,
},
optionTitle: {
fontSize: 16,
fontWeight: '600',
},
optionTitleTablet: {
fontSize: 18,
},
optionDescription: {
fontSize: 13,
marginTop: 2,
},
optionDescriptionTablet: {
fontSize: 15,
},
photoButtonsRow: {
flexDirection: 'row',
width: '100%',
gap: spacing.sm,
},
photoButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
borderRadius: borderRadius.md,
paddingVertical: spacing.sm + 4,
borderWidth: 1,
},
photoButtonTablet: {
paddingVertical: spacing.md,
},
photoButtonText: {
fontSize: 14,
fontWeight: '500',
},
imagePreviewContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingVertical: spacing.xs,
},
imagePreviewText: {
fontSize: 13,
fontWeight: '500',
},
manualInputRow: {
flexDirection: 'row',
width: '100%',
gap: spacing.sm,
},
manualInput: {
flex: 1,
borderRadius: borderRadius.md,
borderWidth: 1,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 4,
fontSize: 15,
},
manualInputTablet: {
fontSize: 18,
paddingVertical: spacing.md,
},
manualSubmitButton: {
backgroundColor: '#7fbf8f',
borderRadius: borderRadius.md,
width: 48,
alignItems: 'center',
justifyContent: 'center',
},
manualSubmitButtonTablet: {
width: 56,
},
});

Some files were not shown because too many files have changed in this diff Show More