diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 066a79e..a2c82cf 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -20,7 +20,24 @@ "WebFetch(domain:dev.to)", "Bash(command -v npx node java)", "Bash(npx --version)", - "Bash(npx cap *)" + "Bash(npx cap *)", + "Bash(pacman -Si jdk17-openjdk)", + "Bash(yay -Si android-sdk-cmdline-tools-latest)", + "Bash(sudo pacman -S --needed --noconfirm jdk17-openjdk)", + "Bash(sudo -n pacman -S --needed --noconfirm jdk17-openjdk)", + "Bash(java -version)", + "Read(//opt/android-sdk/**)", + "Read(//usr/lib/jvm/**)", + "Bash(getent group *)", + "Bash(cat)", + "Bash(grep -q '^android/local.properties$' /home/anunez/Projects/FarmaFinder/.gitignore)", + "Bash(JAVA_HOME=/usr/lib/jvm/java-17-openjdk ANDROID_HOME=/opt/android-sdk ANDROID_SDK_ROOT=/opt/android-sdk PATH=/usr/lib/jvm/java-17-openjdk/bin:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:__TRACKED_VAR__ ./gradlew --no-daemon assembleDebug)", + "Bash(shred -u /tmp/.farmafinder-keystore-pass)", + "Read(//tmp/**)", + "Bash(JAVA_HOME=/usr/lib/jvm/java-17-openjdk /usr/lib/jvm/java-17-openjdk/bin/jarsigner -verify -verbose:summary /home/anunez/Projects/FarmaFinder/android/app/build/outputs/bundle/release/app-release.aab)", + "Bash(openssl pkcs7 *)", + "Bash(openssl x509 *)", + "Bash(/opt/android-sdk/build-tools/34.0.0/apksigner verify *)" ] } } diff --git a/.gitignore b/.gitignore index 5184964..9e0c207 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,11 @@ build/ .DS_Store *.log +# Local Android SDK path — machine-specific +android/local.properties +ios/App/Pods/ + +# Android release signing — NEVER commit +android/keystore.properties +android/app/*.jks +android/app/*.keystore diff --git a/NATIVE.md b/NATIVE.md new file mode 100644 index 0000000..09cfc90 --- /dev/null +++ b/NATIVE.md @@ -0,0 +1,196 @@ +# Native Mobile App Build Guide + +FarmaFinder ships as a PWA and is wrapped for the App Store / Play Store via [Capacitor 6](https://capacitorjs.com). The web frontend in `frontend/` is bundled into each native shell — there's no separate codebase for Android or iOS. + +``` +FarmaFinder/ +├── frontend/ # React + Vite PWA (the actual UI) +├── backend/ # Node API +├── android/ # Capacitor-generated Android Studio project +├── ios/ # Capacitor-generated Xcode project +└── capacitor.config.json +``` + +The `android/` and `ios/` folders are real native projects — open them in their IDEs, change Swift / Kotlin / Gradle / xib as needed. + +--- + +## One-time setup + +### Common (any OS) +```bash +npm run install:all # root + frontend + backend deps +``` + +### Android prerequisites +- **JDK 21** (or 17). Install via your package manager or [Adoptium](https://adoptium.net/). +- **Android Studio** (Hedgehog or later) with the Android SDK Platform 34 installed. +- Set `ANDROID_HOME` and add `platform-tools` to `PATH`, e.g.: + ```bash + export ANDROID_HOME="$HOME/Android/Sdk" + export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH" + ``` +- Open `android/` once in Android Studio so Gradle resolves the dependencies. + +### iOS prerequisites (macOS only) +- **Xcode 15+** from the App Store. Run it once and accept the licence. +- **Command Line Tools**: `xcode-select --install` +- **CocoaPods**: `sudo gem install cocoapods` (or `brew install cocoapods`) +- After cloning, run pod install inside `ios/App`: + ```bash + cd ios/App && pod install && cd - + ``` + +--- + +## Day-to-day workflow + +1. **Build the web bundle** + ```bash + npm run build:web + ``` + Produces `frontend/dist/`, which Capacitor copies into the native projects. + +2. **Sync into the native shells** + ```bash + npm run cap:sync + ``` + - Copies `frontend/dist/` → `android/app/src/main/assets/public/` and `ios/App/App/public/` + - Re-runs `pod install` if new plugins were added + - Updates `capacitor.config.json` inside each platform + +3. **Open the native project in its IDE** + ```bash + npm run cap:open:android # Android Studio + npm run cap:open:ios # Xcode (macOS only) + ``` + +4. **Run on a device or emulator** + ```bash + npm run cap:run:android + npm run cap:run:ios + ``` + These build the web bundle, sync, and launch on the first connected device/emulator. + +After editing anything under `frontend/`, repeat steps 1 + 2 (or use `cap:sync`). Editing native files (Gradle, Swift, Kotlin, plist) is done directly inside `android/` and `ios/` — those edits survive `cap sync`. + +--- + +## Pointing the app at the backend + +The native bundle loads `index.html` from inside the app (`capacitor://localhost/` on iOS, `https://localhost/` on Android by default), so **relative `/api/...` paths do not reach the backend**. Pick one of: + +### Option A — wrap the deployed PWA (simplest) +Add a `server.url` to `capacitor.config.json` so the WebView loads the production site: +```json +{ + "server": { + "url": "https://farmacias.hacecalor.net", + "cleartext": false, + "androidScheme": "https" + } +} +``` +Run `npm run cap:sync` after the change. The native app is now a thin shell around the live PWA — fastest to ship, but useless without a network. + +### Option B — bundled web + absolute API URLs (recommended) +Keep the web assets bundled (no `server.url`) and rewrite frontend `fetch()` calls to use an absolute backend URL. The path of least resistance: + +1. Add a Vite env var to `frontend/.env.production`: + ``` + VITE_API_BASE=https://farmacias.hacecalor.net + ``` +2. Centralise `fetch` calls through a helper (e.g. `frontend/src/utils/api.js`): + ```js + const BASE = import.meta.env.VITE_API_BASE || ''; + export const apiUrl = (path) => `${BASE}${path.startsWith('/') ? path : '/' + path}`; + ``` +3. Replace `fetch('/api/...')` with `fetch(apiUrl('/api/...'))` across the components. +4. On the backend, set `CORS_ORIGIN=https://farmacias.hacecalor.net,capacitor://localhost,https://localhost` so the native WebView origin is allowed. + +This lets the app work offline for cached UI and only hits the network for live data. + +--- + +## Permissions already declared + +The two manifests are pre-wired for the features the PWA already uses: + +**`android/app/src/main/AndroidManifest.xml`** +- `INTERNET` — network calls +- `ACCESS_FINE_LOCATION` / `ACCESS_COARSE_LOCATION` — "Sort by distance" +- `POST_NOTIFICATIONS` — Android 13+ push opt-in + +**`ios/App/App/Info.plist`** +- `NSLocationWhenInUseUsageDescription` — shown by the OS when geolocation is requested +- `NSLocationAlwaysAndWhenInUseUsageDescription` — same for background-style requests +- `UIBackgroundModes: remote-notification` — required for APNs push delivery + +If you add features that need new permissions (camera, contacts, etc.), edit those files directly. + +--- + +## App icons & splash screens + +Capacitor generated placeholder assets: +- Android: `android/app/src/main/res/mipmap-*/ic_launcher*.png` +- iOS: `ios/App/App/Assets.xcassets/AppIcon.appiconset/` +- Splash: `ios/App/App/Assets.xcassets/Splash.imageset/` and the Android theme + +Replace these with real artwork before shipping. The easiest way is the official asset generator — drop a single 1024×1024 PNG (and a foreground/background for adaptive icons) into a `resources/` folder at the project root and run: +```bash +npx @capacitor/assets generate --iconBackgroundColor '#0f766e' --splashBackgroundColor '#0f766e' +``` +(install once: `npm install --save-dev @capacitor/assets`) + +Splash colour and behaviour are configured in `capacitor.config.json` (`plugins.SplashScreen`). The runtime hook in `frontend/src/utils/native.js` hides the splash after React mounts. + +--- + +## Push notifications on native + +The web PWA uses VAPID + the browser PushManager. **Native iOS and Android cannot use Web Push.** To deliver push from the existing backend to native shells: + +- **Android** — set up Firebase Cloud Messaging, add `android/app/google-services.json`, install `@capacitor/push-notifications`. The backend then sends to FCM tokens instead of (or in addition to) the existing Web Push subscriptions. +- **iOS** — create an APNs key in the Apple Developer portal, enable the Push capability in Xcode, configure the `aps-environment` entitlement, install `@capacitor/push-notifications`. + +Until those are wired up, the bell button in the native app is a no-op — `pushSupported()` in `frontend/src/utils/notifications.js` returns false because there's no service worker in the native WebView. + +--- + +## Releasing + +### Android +1. In Android Studio: **Build → Generate Signed Bundle / APK → Android App Bundle**. +2. Create or reuse a keystore; the password unlocks the release signing config. +3. Bump `versionCode` and `versionName` in `android/app/build.gradle` for each release. +4. Upload the produced `.aab` to Google Play Console. + +### iOS +1. In Xcode: select an **Any iOS Device (arm64)** destination → **Product → Archive**. +2. From the Organizer, **Distribute App → App Store Connect**. +3. Bump `CURRENT_PROJECT_VERSION` (build) and `MARKETING_VERSION` (display) under the App target's *General* tab. +4. Submit via App Store Connect. + +Both stores require: privacy policy URL, screenshots (multiple device sizes), an app description, and an age rating questionnaire. The pharmacy domain typically triggers an extra medical-content review on iOS — keep marketing copy factual. + +--- + +## Updating Capacitor / plugins + +```bash +npm install @capacitor/core@latest @capacitor/cli@latest \ + @capacitor/android@latest @capacitor/ios@latest \ + @capacitor/app@latest @capacitor/splash-screen@latest @capacitor/status-bar@latest +npm run cap:sync +``` +After major upgrades follow the migration guide at https://capacitorjs.com/docs/updating. + +--- + +## Troubleshooting + +- **`cap sync` says `Could not find platform 'android'`** — you removed the folder. Re-create with `npx cap add android`. +- **iOS build fails with `Sandbox: rsync ... deny`** — clean build folder (`Cmd-Shift-K`), then `pod install` inside `ios/App`. +- **WebView shows a white screen on Android 14** — verify `android:usesCleartextTraffic` isn't blocking your dev backend (HTTPS only by default in `capacitor.config.json`). +- **Geolocation never returns** — Android 14 requires both `ACCESS_FINE_LOCATION` *and* a runtime grant via the system dialog; iOS requires the `Info.plist` keys above plus enabling Location on the simulator (`Features → Location`). diff --git a/android/app/build.gradle b/android/app/build.gradle index e83f3d1..6e0543f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,5 +1,11 @@ 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.farmafinder" compileSdk rootProject.ext.compileSdkVersion @@ -16,10 +22,24 @@ android { 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 false + minifyEnabled true + shrinkResources true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + if (keystorePropertiesFile.exists()) { + signingConfig signingConfigs.release + } } } } diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index fdb4970..8c6bf7c 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -9,7 +9,9 @@ android { apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { - + implementation project(':capacitor-app') + implementation project(':capacitor-splash-screen') + implementation project(':capacitor-status-bar') } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index f1b4245..b71f09a 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -19,3 +19,27 @@ # 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 ; +} + +# Helpful stack traces from release builds. +-keepattributes SourceFile,LineNumberTable +-renamesourcefileattribute SourceFile diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4d7ca38..3d74400 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -38,4 +38,7 @@ + + + diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index 9a5fa87..df38bc9 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -1,3 +1,12 @@ // 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-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') diff --git a/frontend/src/components/MedicineResults.css b/frontend/src/components/MedicineResults.css index fa524e7..0546dd7 100644 --- a/frontend/src/components/MedicineResults.css +++ b/frontend/src/components/MedicineResults.css @@ -3,6 +3,10 @@ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1.25rem; margin-top: 1rem; + max-height: 60vh; + overflow-y: auto; + overscroll-behavior: contain; + padding: 0.5rem 0.5rem 0.5rem 0; animation: fadeInUp 0.8s ease-out 0.4s backwards; } diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 6d961d2..04d4abf 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -2,6 +2,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; import './index.css'; +import { initNativeShell } from './utils/native'; ReactDOM.createRoot(document.getElementById('root')).render( @@ -9,3 +10,5 @@ ReactDOM.createRoot(document.getElementById('root')).render( ); +initNativeShell(); + diff --git a/frontend/src/utils/native.js b/frontend/src/utils/native.js new file mode 100644 index 0000000..5c947ef --- /dev/null +++ b/frontend/src/utils/native.js @@ -0,0 +1,38 @@ +// Capacitor bootstrap — no-ops on the web, configures native shell on mobile. +// Plugins are dynamic-imported so the web bundle isn't penalized. + +let initStarted = false; + +export function isNativeApp() { + return typeof window !== 'undefined' && Boolean(window.Capacitor?.isNativePlatform?.()); +} + +export async function initNativeShell() { + if (initStarted || !isNativeApp()) return; + initStarted = true; + + try { + const { StatusBar, Style } = await import('@capacitor/status-bar'); + await StatusBar.setBackgroundColor({ color: '#0f766e' }).catch(() => {}); + await StatusBar.setStyle({ style: Style.Light }).catch(() => {}); + } catch { + /* plugin not present — fine on web */ + } + + try { + const { SplashScreen } = await import('@capacitor/splash-screen'); + await SplashScreen.hide({ fadeOutDuration: 250 }).catch(() => {}); + } catch { + /* plugin not present — fine on web */ + } + + try { + const { App } = await import('@capacitor/app'); + App.addListener('backButton', ({ canGoBack }) => { + if (canGoBack) window.history.back(); + else App.exitApp(); + }); + } catch { + /* plugin not present — fine on web */ + } +} diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist index 9c5d1ac..ebdfc23 100644 --- a/ios/App/App/Info.plist +++ b/ios/App/App/Info.plist @@ -45,5 +45,13 @@ UIViewControllerBasedStatusBarAppearance + NSLocationWhenInUseUsageDescription + FarmaFinder uses your location to sort nearby pharmacies by distance. + NSLocationAlwaysAndWhenInUseUsageDescription + FarmaFinder uses your location to sort nearby pharmacies by distance. + UIBackgroundModes + + remote-notification + diff --git a/ios/App/Podfile b/ios/App/Podfile index c66ecd4..38d4553 100644 --- a/ios/App/Podfile +++ b/ios/App/Podfile @@ -11,7 +11,9 @@ install! 'cocoapods', :disable_input_output_paths => true def capacitor_pods pod 'Capacitor', :path => '../../node_modules/@capacitor/ios' pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios' - + pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app' + pod 'CapacitorSplashScreen', :path => '../../node_modules/@capacitor/splash-screen' + pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar' end target 'App' do diff --git a/package-lock.json b/package-lock.json index d9609c8..2472220 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,11 @@ "version": "1.0.0", "dependencies": { "@capacitor/android": "^6.2.1", + "@capacitor/app": "^6.0.3", "@capacitor/core": "^6.2.1", - "@capacitor/ios": "^6.2.1" + "@capacitor/ios": "^6.2.1", + "@capacitor/splash-screen": "^6.0.4", + "@capacitor/status-bar": "^6.0.3" }, "devDependencies": { "@capacitor/cli": "^6.2.1", @@ -26,6 +29,15 @@ "@capacitor/core": "^6.2.0" } }, + "node_modules/@capacitor/app": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@capacitor/app/-/app-6.0.3.tgz", + "integrity": "sha512-4gFUCbcVz0N/YYN32OBFerocWXslIv3Nc90gDiRsBkJc0plwK6kIUT6PKa5WtW2kfhteUeCVXQbvArH2fH+0Ug==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^6.0.0" + } + }, "node_modules/@capacitor/cli": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-6.2.1.tgz", @@ -90,6 +102,24 @@ "@capacitor/core": "^6.2.0" } }, + "node_modules/@capacitor/splash-screen": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@capacitor/splash-screen/-/splash-screen-6.0.4.tgz", + "integrity": "sha512-uJXR+28cdaie7zIIUBvgkWgHim6Gr1itJym9voIMTmrjXkOaPtejwxYJsdQWPJz9zgGnSbXuC1mNNibLgv3OpQ==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^6.0.0" + } + }, + "node_modules/@capacitor/status-bar": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-6.0.3.tgz", + "integrity": "sha512-nFlgSmtx6Zwaw0tEvZgQsWHBeOfWWB/AvEoCApopLT4mHkBVoSrwkLvy2PjZs5wxCbsmqvQczr3XCyTwaDZVQg==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^6.0.0" + } + }, "node_modules/@ionic/cli-framework-output": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz", diff --git a/package.json b/package.json index 7bb01ff..2c5fd20 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,14 @@ "start": "npm-run-all --parallel start:backend start:frontend", "start:backend": "npm start --prefix backend", "start:frontend": "npm run preview --prefix frontend", - "install:all": "npm install && npm install --prefix backend && npm install --prefix frontend" + "install:all": "npm install && npm install --prefix backend && npm install --prefix frontend", + "build:web": "npm run build --prefix frontend", + "cap:sync": "npm run build:web && cap sync", + "cap:copy": "npm run build:web && cap copy", + "cap:open:android": "cap open android", + "cap:open:ios": "cap open ios", + "cap:run:android": "npm run build:web && cap run android", + "cap:run:ios": "npm run build:web && cap run ios" }, "devDependencies": { "@capacitor/cli": "^6.2.1", @@ -18,7 +25,10 @@ }, "dependencies": { "@capacitor/android": "^6.2.1", + "@capacitor/app": "^6.0.3", "@capacitor/core": "^6.2.1", - "@capacitor/ios": "^6.2.1" + "@capacitor/ios": "^6.2.1", + "@capacitor/splash-screen": "^6.0.4", + "@capacitor/status-bar": "^6.0.3" } }