Compare commits

...

27 commits
v1.9.0 ... main

Author SHA1 Message Date
ac84ae707b chore(release): 1.10.1 2025-06-01 10:47:05 +02:00
a2f476f8ee fix: remove deprecated 2025-06-01 10:46:50 +02:00
cbcee9f35f chore(release): 1.10.0 2025-06-01 09:59:48 +02:00
f3cca8182c feat(follow-location): map bubble 2025-05-31 18:19:58 +02:00
ce11db9863 feat(follow-location): map wip 2025-05-31 11:59:12 +02:00
30aeb2a0e4 feat(follow-location): overview initial position 2025-05-31 09:51:22 +02:00
c30c0b0482 feat(follow-location): wip 2025-05-24 16:15:50 +02:00
ca3a2c8fcc feat(follow-location): init 2025-05-24 15:02:51 +02:00
991b65d990 fix: call emergency by default on yellow 2025-05-24 14:58:56 +02:00
3e70ff23c9 feat: contribute 2025-05-24 14:38:55 +02:00
eac1c1d5bd chore: improve scripting 2025-05-24 13:48:05 +02:00
4810cade9f docs: fix funding link 2025-05-23 22:38:17 +02:00
52505eb457 chore: improve doc + add funding 2025-05-23 22:30:21 +02:00
8867a95fb8 chore: add funding 2025-05-23 15:20:35 +02:00
25241589ba chore(release): 1.9.2 2025-05-16 10:13:39 +02:00
2e35c41e0f fix(ios-reported-bug): app only displayed the splash screen after enabling access to location 2025-05-16 10:13:31 +02:00
2ba8a37ed0 chore(release): 1.9.1 2025-05-14 12:48:11 +02:00
8b5d0621fe fix: default call emergency on yellow 2025-05-14 09:06:55 +02:00
bf344a1f27 docs: add README 2025-05-10 11:22:28 +02:00
21495617bb chore: improve screenshot scripts 2025-05-09 09:49:14 +02:00
42bf5c5995 fix: wording 2025-05-07 15:21:09 +02:00
af477ce835 fix(geoloc): server switch + proper auth throttling 2025-05-04 15:09:24 +02:00
2f3db5adc0 chore: add loglevel debug helpers 2025-05-04 13:06:51 +02:00
5398f94f49 chore: add location debug helpers 2025-05-04 12:39:03 +02:00
40b27bff29 fix: staging persistence + disconnect and geo auth reload loop 2025-05-02 08:59:17 +02:00
91c374756b chore: fix install-android 2025-05-01 17:18:17 +02:00
882e6105f4 chore: add screenshot shortcuts 2025-04-27 11:07:05 +02:00
53 changed files with 2172 additions and 74 deletions

3
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1,3 @@
liberapay: alerte-secours
github: alerte-secours
buy_me_a_coffee: alertesecours

2
.gitignore vendored
View file

@ -95,3 +95,5 @@ android/app/google-services.json
!ios/AlerteSecours/GoogleService-Info.example.plist !ios/AlerteSecours/GoogleService-Info.example.plist
!ios/AlerteSecours/Supporting/Expo.example.plist !ios/AlerteSecours/Supporting/Expo.example.plist
!android/app/google-services.example.json !android/app/google-services.example.json
screenshot-*.png

View file

@ -2,6 +2,47 @@
All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines. All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
## [1.10.1](https://github.com/alerte-secours/as-app/compare/v1.10.0...v1.10.1) (2025-06-01)
### Bug Fixes
* remove deprecated ([a2f476f](https://github.com/alerte-secours/as-app/commit/a2f476f8ee9670461dc7babc7f534c43ba4708e9))
## [1.10.0](https://github.com/alerte-secours/as-app/compare/v1.9.2...v1.10.0) (2025-06-01)
### Features
* contribute ([3e70ff2](https://github.com/alerte-secours/as-app/commit/3e70ff23c9361a5d331e15160adbb44a8bf773ac))
* **follow-location:** init ([ca3a2c8](https://github.com/alerte-secours/as-app/commit/ca3a2c8fcce2aaa613817bde047f143915ef8fe0))
* **follow-location:** map bubble ([f3cca81](https://github.com/alerte-secours/as-app/commit/f3cca8182ca52a89f7f6d0785821185db277bbf0))
* **follow-location:** map wip ([ce11db9](https://github.com/alerte-secours/as-app/commit/ce11db9863a7aaa14b28e104645d8ce0b23a235b))
* **follow-location:** overview initial position ([30aeb2a](https://github.com/alerte-secours/as-app/commit/30aeb2a0e41196719977900e08b12d8ae813a69a))
* **follow-location:** wip ([c30c0b0](https://github.com/alerte-secours/as-app/commit/c30c0b0482d946b6b4d6a2421416a59f9d57bfeb))
### Bug Fixes
* call emergency by default on yellow ([991b65d](https://github.com/alerte-secours/as-app/commit/991b65d990b909fb02f9941fedf84637d0a8b71e))
## [1.9.2](https://github.com/alerte-secours/as-app/compare/v1.9.1...v1.9.2) (2025-05-16)
### Bug Fixes
* **ios-reported-bug:** app only displayed the splash screen after enabling access to location ([2e35c41](https://github.com/alerte-secours/as-app/commit/2e35c41e0f3e968df6dc07e7656cf50509557a7c))
## [1.9.1](https://github.com/alerte-secours/as-app/compare/v1.9.0...v1.9.1) (2025-05-14)
### Bug Fixes
* default call emergency on yellow ([8b5d062](https://github.com/alerte-secours/as-app/commit/8b5d0621fed5fafaa366fd16b9280509e3923ca4))
* **geoloc:** server switch + proper auth throttling ([af477ce](https://github.com/alerte-secours/as-app/commit/af477ce8352599124e20894bd1fbdb297317e4cb))
* staging persistence + disconnect and geo auth reload loop ([40b27bf](https://github.com/alerte-secours/as-app/commit/40b27bff297cdf70f45c66009275110c91a60269))
* wording ([42bf5c5](https://github.com/alerte-secours/as-app/commit/42bf5c599520cdd55943fde5f5fc052c72b7991a))
## 1.9.0 (2025-04-24) ## 1.9.0 (2025-04-24)

226
DEVELOPER.md Normal file
View file

@ -0,0 +1,226 @@
# Alerte Secours Mobile App - Developer Documentation
This document contains technical information for developers working on the Alerte Secours mobile application.
## Table of Contents
- [Project Overview](#project-overview)
- [Technical Stack](#technical-stack)
- [Development Quick Start](#development-quick-start)
- [Installation](#installation)
- [Android](#android)
- [iOS](#ios)
- [Project Structure](#project-structure)
- [Troubleshooting](#troubleshooting)
## Project Overview
Alerte Secours is a mobile application built with React Native that handles alerts and emergency-related functionality. The app supports both iOS and Android platforms and includes features such as:
- Alert creation and management with real-time updates
- Location-based features with mapping integration
- Chat/Messaging system with alert-specific chat rooms
- Authentication via SMS verification
- Deep linking for alert sharing
- Push notifications
## Technical Stack
- React Native
- Expo framework
- GraphQL with Hasura
- Apollo Client for frontend
- Federated remote schemas
- Real-time subscriptions support
- Firebase Cloud Messaging (FCM) for push notifications only
- Sentry for error tracking
- MapLibre for mapping functionality
- Zustand for state management
- i18next for internationalization
- React Navigation for navigation
- Expo Updates for OTA updates
- Background Geolocation for location tracking
- Lottie for animations
- React Hook Form for form handling
- Axios for HTTP requests
- Yarn Berry as package manager
- ESLint and Prettier for code quality
- Fastlane for deployment automation
## Development Quick Start
### Prerequisites
- Node.js (version specified in `.node-version`)
- Yarn package manager
- Android Studio (for Android development)
- Xcode (for iOS development)
- Physical device or emulator/simulator
### Environment Setup
1. Clone the repository
2. Install dependencies:
```bash
yarn
```
3. Copy the staging environment file:
```bash
cp .env.staging.example .env.staging
```
4. Start the development server with staging environment:
```bash
yarn start:staging
```
### Running on Devices
#### Android
```bash
yarn android:staging
```
#### iOS
```bash
yarn ios:staging
```
### Staging URLs
The staging environment uses the following URLs:
- GraphQL API: `https://hasura-staging.alertesecours.fr/v1/graphql`
- WebSocket: `wss://hasura-staging.alertesecours.fr/v1/graphql`
- Files API: `https://files-staging.alertesecours.fr/api/v1/oas`
- Minio: `https://minio-staging.alertesecours.fr`
- Geolocation Sync: `https://api-staging.alertesecours.fr/api/v1/oas/geoloc/sync`
## Installation
### Android
#### Using the Yarn Script
The easiest way to install the app is to use the provided yarn script:
```bash
# Set the device ID (emulator or physical device)
export DEVICE=emulator-5554
# Run the installation script
yarn install:android
```
This script (`install-android.sh`) handles the entire installation process, including building APKs with signing, extracting them, and installing on the device.
#### Manual Installation
If you need to install the app manually, you can examine the `install-android.sh` script in the project root to see the detailed steps involved.
### iOS
#### Authentication Key Setup
1. Go to https://appstoreconnect.apple.com/access/integrations/api
2. Click the "+" button to generate a new API key
3. Give it a name (e.g., "AlerteSecours Build Key")
4. Download the .p8 file when prompted
5. Store the .p8 file in a secure location
6. Note down the Key ID and Issuer ID shown on the website
7. Set up environment variables:
```sh
export ASC_API_KEY_ID="YOUR_KEY_ID"
export ASC_API_ISSUER_ID="YOUR_ISSUER_ID"
export ASC_API_KEY_PATH="/path/to/your/AuthKey.p8"
```
#### Building and Running
To build and run the iOS app:
```bash
# Run in development mode with staging environment
yarn ios:staging
# Build for production (uses scripts/ios-archive.sh and scripts/ios-export.sh)
yarn bundle:ios
```
The `bundle:ios` command uses the scripts in the `scripts` directory:
- `ios-archive.sh` - Archives the iOS app
- `ios-export.sh` - Exports the archived app
- `ios-upload.sh` - Uploads the app to App Store Connect (used by `bundle:ios:upload`)
## Project Structure
- `/android` - Android-specific code and configuration
- `/ios` - iOS-specific code and configuration
- `/src` - Main application source code
- `/app` - App initialization and configuration
- `/assets` - Static assets (images, fonts, animations)
- `/auth` - Authentication-related code
- `/biz` - Business logic and constants
- `/components` - Reusable UI components
- `/containers` - Container components
- `/data` - Data management
- `/events` - Event handling
- `/finders` - Search and finder utilities
- `/gql` - GraphQL queries and mutations
- `/hoc` - Higher-order components
- `/hooks` - Custom React hooks
- `/i18n` - Internationalization
- `/layout` - Layout components
- `/lib` - Library code
- `/location` - Location-related functionality
- `/misc` - Miscellaneous utilities
- `/navigation` - Navigation configuration
- `/network` - Network-related code
- `/notifications` - Notification handling
- `/permissions` - Permission handling
- `/scenes` - Scene components
- `/screens` - Screen components
- `/sentry` - Sentry error tracking configuration
- `/stores` - State management stores
- `/theme` - Styling and theming
- `/updates` - Update handling
- `/utils` - Utility functions
- `/docs` - Documentation files
- `/scripts` - Utility scripts for building, deployment, and development
- `/e2e` - End-to-end tests
## Contributing
Guidelines for contributing to the project:
1. Follow the code style and conventions used in the project
2. Write tests for new features
3. Update documentation as needed
4. Use the ESLint and Prettier configurations
## Troubleshooting
### Common Issues
#### Development Environment
- **Clearing Yarn Cache**: Use `yarn clean` (removes node_modules and reinstalls dependencies)
- **Cleaning Gradle**: Run `cd android && ./gradlew clean`
- **Clearing Gradle Cache**: Remove gradle caches with `rm -rf ~/.gradle/caches/ android/.gradle/`
- **Stopping Gradle Daemons**: Run `cd android && ./gradlew --stop`
- **Clearing ADB Cache**: Run `adb shell pm clear com.alertesecours`
- **Rebuilding Gradle**: Use `yarn expo run:android`
- **Rebuilding Expo React Native**: Use `yarn expo prebuild`
- **Clearing Metro Cache**: Use `yarn expo start --dev-client --clear`
#### Screenshots and Testing
- For Android screenshots: Use `scripts/screenshot-android.sh`
- For iOS screenshots: Use `scripts/screenshot-ios.sh`
- For Android emulator: Use `scripts/android-emulator`
#### Emulator Issues
- Clear cache / uninstall the app
- Check emulator datetime
- Check network connectivity
For more troubleshooting tips, see the documentation in the `/docs` directory.

82
README.md Normal file
View file

@ -0,0 +1,82 @@
# Alerte Secours - Le Réflexe qui Sauve
[![Liberapay](https://img.shields.io/liberapay/receives/alerte-secours.svg?logo=liberapay)](https://liberapay.com/alerte-secours)
[![Buy Me a Coffee](https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?logo=buy-me-a-coffee)](https://buymeacoffee.com/alertesecours)
[![GitHub Sponsors](https://img.shields.io/github/sponsors/alerte-secours?style=social)](https://github.com/sponsors/alerte-secours)
Une application mobile pour la gestion des alertes et des fonctionnalités liées aux urgences, supportant les plateformes iOS et Android.
**Site Web Officiel :** [alerte-secours.fr](https://alerte-secours.fr)
## Aperçu du Projet
Alerte Secours est une application mobile construite avec React Native qui gère les alertes et les fonctionnalités liées aux urgences. L'application supporte les plateformes iOS et Android et inclut des fonctionnalités telles que :
- Création et gestion d'alertes avec mises à jour en temps réel
- Fonctionnalités basées sur la localisation avec intégration cartographique
- Système de chat/messagerie avec salles de discussion spécifiques aux alertes
- Authentification via vérification SMS
- Liens profonds pour le partage d'alertes
- Notifications push
## Documentation Développeur
Pour les développeurs souhaitant contribuer au projet ou déployer l'application, consultez la [documentation technique complète](DEVELOPER.md) qui contient :
- Aperçu du projet et fonctionnalités
- Stack technique détaillé
- Guide de démarrage rapide
- Instructions d'installation (Android/iOS)
- Structure du projet
- Guide de dépannage
- Instructions de build et déploiement
## Licence
Alerte Secours est sous licence **DevTheFuture Ethical Use License (DEF License)**. Points clés :
### Usage à but non lucratif
- Licence perpétuelle, libre de redevances et non exclusive pour usage à but non lucratif
- Permet l'utilisation, la modification et la distribution à des fins non lucratives
### Usage commercial
- Nécessite l'obtention d'une licence payante
- Conditions déterminées par le Concédant (DevTheFuture.org)
### Restrictions sur les données personnelles
- Ne doit pas être utilisé pour monétiser, vendre ou exploiter les données personnelles
- Les données personnelles ne peuvent pas être utilisées pour le marketing, la publicité ou l'influence politique
- L'agrégation de données n'est autorisée que si c'est une fonctionnalité explicite divulguée aux utilisateurs
### Restriction concurrentielle
- Les concurrents sont interdits d'utiliser le logiciel sans consentement explicite
Pour le texte complet de la licence, voir [LICENSE.md](LICENSE.md).
## 💙 Soutenir le projet
Alerte-Secours est une application mobile citoyenne, librement accessible, sans publicité ni exploitation de données.
Si vous souhaitez contribuer à son développement, sa maintenance et son indépendance :
- 🟡 **[Liberapay Soutien régulier](https://liberapay.com/alerte-secours)**
Pour un soutien **récurrent et engagé**. Chaque don contribue à assurer la stabilité du service sur le long terme.
- ☕ **[Buy Me a Coffee Don ponctuel](https://buymeacoffee.com/alertesecours)**
Pour un **coup de pouce ponctuel**, un café virtuel pour encourager le travail accompli !
- 🧑‍💻 **[GitHub Sponsors](https://github.com/sponsors/alerte-secours)**
Pour les développeurs et utilisateurs de GitHub : soutenez le projet directement via votre compte.
## Contribuer
Directives pour contribuer au projet :
1. Suivez le style de code et les conventions utilisées dans le projet
2. Écrivez des tests pour les nouvelles fonctionnalités
3. Mettez à jour la documentation si nécessaire
4. Utilisez les configurations ESLint et Prettier
## Support
Pour obtenir de l'aide, veuillez ouvrir un ticket sur notre tracker d'issues ou consulter la documentation dans le répertoire `/docs`.

View file

@ -83,8 +83,8 @@ Project background_fetch = project(':react-native-background-fetch')
applicationId 'com.alertesecours' applicationId 'com.alertesecours'
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 177 versionCode 181
versionName "1.9.0" versionName "1.10.1"
multiDexEnabled true multiDexEnabled true
testBuildType System.getProperty('testBuildType', 'debug') testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'

99
docs/android-install.md Normal file
View file

@ -0,0 +1,99 @@
# Android App Installation Guide
This guide explains how to install the Android app on an emulator or physical device.
## Prerequisites
- Android emulator or physical device connected via ADB
- Java installed (for bundletool)
- ADB installed and configured
## Installation
### Using the Yarn Script
The easiest way to install the app is to use the provided yarn script:
```bash
# Set the device ID (emulator or physical device)
export DEVICE=emulator-5554
# Run the installation script
yarn install:android
```
### Manual Installation
If you need to install the app manually, follow these steps:
1. Navigate to the bundle release directory:
```bash
cd android/app/build/outputs/bundle/release
```
2. Build APKs with signing:
```bash
java -jar /opt/bundletool-all-1.17.1.jar build-apks \
--mode universal \
--bundle ./app-release.aab \
--output ./app.apks \
--ks /home/jo/lab/alerte-secours/as-app/android/app/debug.keystore \
--ks-pass pass:android \
--ks-key-alias androiddebugkey \
--key-pass pass:android
```
3. Convert .apks to .zip and extract:
```bash
mv app.apks app.zip
unzip -o app.zip
```
4. Install the APK on the device:
```bash
adb -s $DEVICE install universal.apk
```
## Troubleshooting
### Finding Available Devices
To list all available devices:
```bash
adb devices
```
Example output:
```
List of devices attached
emulator-5554 device
```
### Common Issues
1. **No devices found**: Make sure your emulator is running or your physical device is connected and has USB debugging enabled.
2. **Installation fails**: Check if the app is already installed. You might need to uninstall it first:
```bash
adb -s $DEVICE uninstall com.alertesecours
```
3. **Signing issues**: If you encounter signing problems, make sure the keystore path is correct and the keystore passwords match.
## Custom Installation Script
A custom installation script (`install-android.sh`) has been created to simplify the installation process. This script:
1. Checks if the DEVICE environment variable is set
2. Navigates to the bundle release directory
3. Builds APKs with signing
4. Converts .apks to .zip and extracts it
5. Installs the APK on the device
You can run this script directly:
```bash
export DEVICE=emulator-5554
./install-android.sh
```

30
install-android.sh Executable file
View file

@ -0,0 +1,30 @@
#!/bin/bash
# Check if DEVICE environment variable is set
if [ -z "$DEVICE" ]; then
echo "Error: DEVICE environment variable is not set."
echo "Usage: DEVICE=emulator-5554 ./install-android.sh"
exit 1
fi
# Navigate to the bundle release directory
cd android/app/build/outputs/bundle/release
# Build APKs with signing
java -jar /opt/bundletool-all-1.17.1.jar build-apks \
--mode universal \
--bundle ./app-release.aab \
--output ./app.apks \
--ks $HOME/lab/alerte-secours/as-app/android/app/debug.keystore \
--ks-pass pass:android \
--ks-key-alias androiddebugkey \
--key-pass pass:android
# Convert .apks to .zip and extract
mv app.apks app.zip
unzip -o app.zip
# Install the APK on the device
adb -s $DEVICE install universal.apk
echo "Installation complete!"

View file

@ -19,7 +19,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.9.0</string> <string>1.10.1</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@ -42,7 +42,7 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>177</string> <string>181</string>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
<false/> <false/>
<key>LSApplicationQueriesSchemes</key> <key>LSApplicationQueriesSchemes</key>

View file

@ -1,6 +1,6 @@
{ {
"name": "alerte-secours", "name": "alerte-secours",
"version": "1.9.0", "version": "1.10.1",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"start": "expo start --dev-client --private-key-path ./keys/private-key.pem", "start": "expo start --dev-client --private-key-path ./keys/private-key.pem",
@ -36,7 +36,7 @@
"e2e:start": "adb -s emulator-5554 shell am start -n com.alertesecours/.MainActivity", "e2e:start": "adb -s emulator-5554 shell am start -n com.alertesecours/.MainActivity",
"e2e:test": "detox test --configuration android.emu.debug", "e2e:test": "detox test --configuration android.emu.debug",
"e2e:run": "yarn start & yarn e2e:build && yarn e2e:deploy && yarn e2e:test", "e2e:run": "yarn start & yarn e2e:build && yarn e2e:deploy && yarn e2e:test",
"install:android": "cd android/app/build/outputs/bundle/release && java -jar /opt/bundletool-all-1.17.1.jar build-apks --mode universal --bundle ./app-release.aab --output ./app.apks && mv app.apks app.zip && unzip -o app.zip && adb -s $DEVICE install universal.apk", "install:android": "./install-android.sh",
"log:android": "adb -s $DEVICE logcat | grep -E 'ReactNativeJS: '", "log:android": "adb -s $DEVICE logcat | grep -E 'ReactNativeJS: '",
"log:ios:simulator": "xcrun simctl spawn booted log stream --level debug --predicate 'subsystem contains \"com.facebook.react.log\" and processImagePath contains \"AlerteSecours\"'", "log:ios:simulator": "xcrun simctl spawn booted log stream --level debug --predicate 'subsystem contains \"com.facebook.react.log\" and processImagePath contains \"AlerteSecours\"'",
"log:ios": "idevicesyslog | grep -i 'AlerteSecours\\|ReactNative'", "log:ios": "idevicesyslog | grep -i 'AlerteSecours\\|ReactNative'",
@ -44,11 +44,13 @@
"log-ios": "react-native log-ios", "log-ios": "react-native log-ios",
"open:deeplink:android": "yarn open:deeplink --android", "open:deeplink:android": "yarn open:deeplink --android",
"open:deeplink:ios": "yarn open:deeplink --ios", "open:deeplink:ios": "yarn open:deeplink --ios",
"open:deeplink": "npx uri-scheme open --android" "open:deeplink": "npx uri-scheme open --android",
"screenshot:ios": "scripts/screenshot-ios.sh",
"screenshot:android": "scripts/screenshot-android.sh"
}, },
"customExpoVersioning": { "customExpoVersioning": {
"versionCode": 177, "versionCode": 181,
"buildNumber": 177 "buildNumber": 181
}, },
"commit-and-tag-version": { "commit-and-tag-version": {
"scripts": { "scripts": {

7
scripts/screenshot-android.sh Executable file
View file

@ -0,0 +1,7 @@
#!/usr/bin/env bash
if [ -z "$DEVICE" ]; then
echo "Error: DEVICE environment variable is not set."
echo "Usage: DEVICE=emulator-5554 ./screenshot-android.sh"
exit 1
fi
exec adb -s $DEVICE exec-out screencap -p > screenshot-emulator-$(date +%s).png

2
scripts/screenshot-ios.sh Executable file
View file

@ -0,0 +1,2 @@
#!/usr/bin/env bash
exec xcrun simctl io booted screenshot ~/Desktop/screenshot-sim-$(date +%s).png

View file

@ -9,11 +9,15 @@ const ALERT_FIELDS_FRAGMENT = gql`
radius radius
alertTag alertTag
location location
initialLocation
createdAt createdAt
closedAt closedAt
address address
what3Words what3Words
nearestPlace nearestPlace
lastAddress
lastWhat3Words
lastNearestPlace
username username
code code
notifiedCount # deprecated notifiedCount # deprecated
@ -25,6 +29,7 @@ const ALERT_FIELDS_FRAGMENT = gql`
acknowledgedRelativeCount acknowledgedRelativeCount
acknowledgedAroundCount acknowledgedAroundCount
acknowledgedConnectCount acknowledgedConnectCount
followLocation
accessCode accessCode
userId userId
keepOpenAt keepOpenAt

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

View file

@ -13,6 +13,9 @@ import { getDeviceUuid } from "./deviceUuid";
export async function registerUser() { export async function registerUser() {
const { data } = await network.apolloClient.mutate({ const { data } = await network.apolloClient.mutate({
mutation: REGISTER_USER_MUTATION, mutation: REGISTER_USER_MUTATION,
context: {
skipAuth: true, // Skip adding Authorization header
},
}); });
const authToken = data.addOneAuthInitToken.authTokenJwt; const authToken = data.addOneAuthInitToken.authTokenJwt;
return { authToken }; return { authToken };
@ -27,6 +30,9 @@ export async function loginUserToken({ authToken }) {
phoneModel: Device.modelName, phoneModel: Device.modelName,
deviceUuid, deviceUuid,
}, },
context: {
skipAuth: true, // Skip adding Authorization header
},
}); });
const userToken = data.doAuthLoginToken.userBearerJwt; const userToken = data.doAuthLoginToken.userBearerJwt;
return { userToken }; return { userToken };

View file

@ -7,17 +7,15 @@ import useTimeDisplay from "~/hooks/useTimeDisplay";
export default function AlertInfoLineClosedTime({ alert, ...props }) { export default function AlertInfoLineClosedTime({ alert, ...props }) {
const { closedAt } = alert; const { closedAt } = alert;
const closedAtText = useTimeDisplay(closedAt); const closedAtText = useTimeDisplay(closedAt);
if (!closedAt) { if (!closedAt) {
return null; return null;
} }
return ( return (
<AlertInfoLine <AlertInfoLine
icon={() => ( iconName={"clock-time-four-outline"}
<MaterialCommunityIcons name="clock-time-four-outline" size={24} /> labelText={`Terminée`}
)} valueText={closedAtText}
text={`Terminée ${closedAtText}`}
{...props} {...props}
/> />
); );

View file

@ -0,0 +1,51 @@
import React from "react";
import { View } from "react-native";
import Text from "~/components/Text";
import AlertInfoLineAddress from "~/containers/AlertInfoLines/Address";
import AlertInfoLineNear from "~/containers/AlertInfoLines/Near";
import AlertInfoLineW3w from "~/containers/AlertInfoLines/W3w";
/**
* LocationInfoSection component displays location information with a title
* and optional address, nearby location, and what3words information.
*
* @param {Object} props - Component props
* @param {string} props.title - Title to display for the location section
* @param {Object} props.alert - Alert object containing location information
* @param {boolean} [props.showAddress=true] - Whether to show the address information
* @param {boolean} [props.showNear=true] - Whether to show the nearby location information
* @param {boolean} [props.showW3w=true] - Whether to show the what3words information
* @param {Object} props.styles - Styles object containing locationSectionTitle and locationTitle styles
* @param {boolean} [props.useLastLocation=false] - Whether to use the last known location instead of current location
* @returns {React.ReactElement} The rendered component
*/
export default function LocationInfoSection({
title,
alert,
showAddress = true,
showNear = true,
showW3w = true,
styles,
useLastLocation = false,
}) {
// Create a modified alert object with the appropriate properties
const displayAlert = useLastLocation
? {
...alert,
address: alert.lastAddress,
nearestPlace: alert.lastNearLocation,
what3Words: alert.lastWhat3Words,
}
: alert;
return (
<>
<View style={styles.locationSectionTitle}>
<Text style={styles.locationTitle}>{title}</Text>
</View>
{showAddress && <AlertInfoLineAddress alert={displayAlert} />}
{showNear && <AlertInfoLineNear alert={displayAlert} />}
{showW3w && <AlertInfoLineW3w alert={displayAlert} />}
</>
);
}

View file

@ -8,6 +8,7 @@ import markerGreen from "~/assets/img/marker-green.png";
import markerRedDisabled from "~/assets/img/marker-red-disabled.png"; import markerRedDisabled from "~/assets/img/marker-red-disabled.png";
import markerYellowDisabled from "~/assets/img/marker-yellow-disabled.png"; import markerYellowDisabled from "~/assets/img/marker-yellow-disabled.png";
import markerGreenDisabled from "~/assets/img/marker-green-disabled.png"; import markerGreenDisabled from "~/assets/img/marker-green-disabled.png";
import markerOrigin from "~/assets/img/marker-origin.png";
const images = { const images = {
red: markerRed, red: markerRed,
@ -16,6 +17,7 @@ const images = {
redDisabled: markerRedDisabled, redDisabled: markerRedDisabled,
yellowDisabled: markerYellowDisabled, yellowDisabled: markerYellowDisabled,
greenDisabled: markerGreenDisabled, greenDisabled: markerGreenDisabled,
origin: markerOrigin,
}; };
export default function FeatureImages() { export default function FeatureImages() {

View file

@ -1,10 +1,15 @@
import React from "react"; import React from "react";
import SelectedFeatureBubbleAlert from "./SelectedFeatureBubbleAlert"; import SelectedFeatureBubbleAlert from "./SelectedFeatureBubbleAlert";
import SelectedFeatureBubbleAlertInitial from "./SelectedFeatureBubbleAlertInitial";
export default function SelectedFeatureBubble(props) { export default function SelectedFeatureBubble(props) {
const { properties = {} } = props.feature; const { properties = {} } = props.feature;
if (properties.alert) { if (properties.alert) {
// Check if this is an initial location marker
if (properties.isInitialLocation) {
return <SelectedFeatureBubbleAlertInitial {...props} />;
}
return <SelectedFeatureBubbleAlert {...props} />; return <SelectedFeatureBubbleAlert {...props} />;
} }
return null; return null;

View file

@ -1,4 +1,5 @@
import React, { useCallback } from "react"; import React, { useCallback, useMemo } from "react";
import { deepEqual } from "fast-equals";
import { View } from "react-native"; import { View } from "react-native";
import { IconButton, TouchableRipple } from "react-native-paper"; import { IconButton, TouchableRipple } from "react-native-paper";
import { MaterialCommunityIcons } from "@expo/vector-icons"; import { MaterialCommunityIcons } from "@expo/vector-icons";
@ -63,6 +64,31 @@ export default function SelectedFeatureBubbleAlert({ feature, close }) {
/> />
</View> </View>
<View style={styles.content}> <View style={styles.content}>
{useMemo(() => {
const isLocationsDifferent =
alert.initialLocation &&
alert.location &&
!deepEqual(alert.initialLocation, alert.location);
if (isLocationsDifferent) {
return (
<View style={styles.titleContainer}>
<Text style={styles.titleText}>
{alert.followLocation
? "Localisation actuelle"
: "Dernière position connue"}
</Text>
</View>
);
}
return null;
}, [
alert.initialLocation,
alert.location,
alert.followLocation,
styles.titleContainer,
styles.titleText,
])}
<View style={styles.contentLine}> <View style={styles.contentLine}>
<Text style={styles.contentText}>Sujet :</Text> <Text style={styles.contentText}>Sujet :</Text>
<Text style={[styles.contentTextValue, { color: levelColor }]}> <Text style={[styles.contentTextValue, { color: levelColor }]}>
@ -123,6 +149,18 @@ const useStyles = createStyles(({ wp, fontSize, theme: { colors } }) => ({
content: { content: {
paddingHorizontal: 4, paddingHorizontal: 4,
}, },
titleContainer: {
marginBottom: 8,
borderBottomWidth: 1,
borderBottomColor: colors.grey,
paddingBottom: 4,
},
titleText: {
color: colors.primary,
fontSize: 16,
fontWeight: "bold",
textAlign: "center",
},
contentLine: { contentLine: {
flexDirection: "row", flexDirection: "row",
justifyContent: "space-between", justifyContent: "space-between",

View file

@ -0,0 +1,185 @@
import React, { useCallback } from "react";
import { View } from "react-native";
import { IconButton, TouchableRipple } from "react-native-paper";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import Maplibre from "@maplibre/maplibre-react-native";
import Text from "~/components/Text";
import useTimeDisplay from "~/hooks/useTimeDisplay";
import { useNavigation } from "@react-navigation/native";
import { createStyles, useTheme } from "~/theme";
import { alertActions } from "~/stores";
export default function SelectedFeatureBubbleAlertInitial({ feature, close }) {
const { properties = {} } = feature;
const { alert } = properties;
const styles = useStyles();
const { colors, custom } = useTheme();
const createdAtText = useTimeDisplay(alert.createdAt);
const navigation = useNavigation();
const goToAlert = useCallback(() => {
alertActions.setNavAlertCur({ alert });
navigation.navigate({
name: "AlertCur",
params: {
screen: "AlertCurTab",
params: {
screen: "AlertCurOverview",
},
},
});
}, [alert, navigation]);
const { level } = alert;
const levelColor = custom.appColors[level];
return (
<Maplibre.MarkerView
key={feature.properties.id}
id="selectedFeaturePointAnnotation"
aboveLayerID="lineLayer"
coordinate={feature.geometry.coordinates}
anchor={{ x: 0, y: 1 }}
>
<View style={styles.bubbleContainer}>
<View
style={{
flexDirection: "row",
justifyContent: "center",
alignSelf: "flex-end",
}}
>
<IconButton
size={14}
style={styles.closeButton}
icon={() => (
<MaterialCommunityIcons
name="close"
size={22}
style={styles.closeButtonIcon}
/>
)}
onPress={() => close()}
/>
</View>
<View style={styles.content}>
<View style={styles.titleContainer}>
<Text style={styles.titleText}>
Localisation initiale de l'Alerte
</Text>
</View>
<View style={styles.contentLine}>
<Text style={styles.contentText}>Sujet :</Text>
<Text style={[styles.contentTextValue, { color: levelColor }]}>
{alert.subject || "non indiqué"}
</Text>
</View>
<View style={styles.contentLine}>
<Text style={[styles.contentText]}>Code :</Text>
<Text style={[styles.contentTextValue]}>#{alert.code}</Text>
</View>
<View style={styles.contentLine}>
<Text style={styles.contentText}>Envoyée par :</Text>
<Text style={styles.contentTextValue}>{alert.username}</Text>
<Text style={styles.contentTextValue}>{createdAtText}</Text>
</View>
<View style={styles.contentLine}>
<Text style={styles.contentText}>Depuis l'adresse :</Text>
<Text style={styles.contentTextValue}>{alert.address}</Text>
</View>
<View style={styles.contentLine}>
<Text style={styles.contentText}>À proximité de :</Text>
<Text style={styles.contentTextValue}>{alert.nearestPlace}</Text>
</View>
<View style={styles.contentLine}>
<Text style={styles.contentText}>Localisation en 3 mots :</Text>
<Text style={styles.contentTextValue}>{alert.what3Words}</Text>
</View>
<TouchableRipple style={styles.alertLinkButton} onPress={goToAlert}>
<View style={styles.alertLinkContent}>
<MaterialCommunityIcons
name="adjust"
style={styles.alertLinkButtonIcon}
/>
<Text style={styles.alertLinkText}>Situation</Text>
</View>
</TouchableRipple>
</View>
</View>
</Maplibre.MarkerView>
);
}
const useStyles = createStyles(({ wp, fontSize, theme: { colors } }) => ({
bubbleContainer: {
backgroundColor: colors.surface,
justifyContent: "center",
alignItems: "flex-start",
paddingHorizontal: 4,
paddingVertical: 2,
borderRadius: 5,
width: wp(75),
},
bubbleText: {
color: colors.onSurface,
fontSize: 16,
},
closeButton: {},
closeButtonIcon: {
color: colors.grey,
},
content: {
paddingHorizontal: 4,
},
titleContainer: {
marginBottom: 8,
borderBottomWidth: 1,
borderBottomColor: colors.grey,
paddingBottom: 4,
},
titleText: {
color: colors.primary,
fontSize: 16,
fontWeight: "bold",
textAlign: "center",
},
contentLine: {
flexDirection: "row",
justifyContent: "space-between",
flexWrap: "wrap",
},
contentText: {
color: colors.onSurface,
fontSize: 15,
paddingRight: 5,
},
contentTextValue: {
color: colors.onSurfaceVariant,
fontSize: 15,
paddingRight: 5,
textAlign: "right",
flexGrow: 1,
},
alertLinkButton: {
marginTop: 15,
marginBottom: 5,
borderRadius: 0,
paddingVertical: 0,
backgroundColor: colors.primary,
},
alertLinkContent: {
height: 28,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
},
alertLinkText: {
color: colors.surface,
fontSize: 15,
},
alertLinkButtonIcon: {
color: colors.surface,
marginRight: 5,
fontSize: 15,
},
}));

View file

@ -12,6 +12,11 @@ const hitbox = {
height: HITBOX_SIZE, height: HITBOX_SIZE,
}; };
const iconStyle = {
iconImage: ["get", "icon"],
iconSize: 0.5,
};
const useStyles = createStyles(({ theme: { colors } }) => ({ const useStyles = createStyles(({ theme: { colors } }) => ({
clusterCount: { clusterCount: {
textField: "{point_count_abbreviated}", textField: "{point_count_abbreviated}",
@ -46,6 +51,13 @@ export default function ShapePoints({ shape, children, ...shapeSourceProps }) {
<AlertSymbolLayer level="yellow" isDisabled /> <AlertSymbolLayer level="yellow" isDisabled />
<AlertSymbolLayer level="green" isDisabled /> <AlertSymbolLayer level="green" isDisabled />
<Maplibre.SymbolLayer
filter={["==", ["get", "icon"], "origin"]}
key="points-origin"
id="points-origin"
style={iconStyle}
/>
{children} {children}
</Maplibre.ShapeSource> </Maplibre.ShapeSource>
); );

View file

@ -1,4 +1,8 @@
import { Platform } from "react-native"; import { Platform } from "react-native";
import { secureStore } from "~/lib/secureStore";
// Key for storing staging setting in secureStore
const STAGING_SETTING_KEY = "env.isStaging";
// Logging configuration // Logging configuration
const LOG_SCOPES = process.env.APP_LOG_SCOPES; const LOG_SCOPES = process.env.APP_LOG_SCOPES;
@ -85,16 +89,50 @@ const stagingMap = {
IS_STAGING: true, IS_STAGING: true,
}; };
export const setStaging = (enabled) => { export const setStaging = async (enabled) => {
for (const key of Object.keys(env)) { for (const key of Object.keys(env)) {
if (stagingMap[key] !== undefined) { if (stagingMap[key] !== undefined) {
env[key] = enabled ? stagingMap[key] : envMap[key]; env[key] = enabled ? stagingMap[key] : envMap[key];
} }
} }
// Persist the staging setting
await secureStore.setItemAsync(STAGING_SETTING_KEY, String(enabled));
}; };
// Initialize with default values
const env = { ...envMap }; const env = { ...envMap };
// Load the staging setting from secureStore
export const initializeEnv = async () => {
try {
const storedStaging = await secureStore.getItemAsync(STAGING_SETTING_KEY);
if (storedStaging !== null) {
const isStaging = storedStaging === "true";
if (isStaging) {
// Apply staging settings without persisting again
for (const key of Object.keys(env)) {
if (stagingMap[key] !== undefined) {
env[key] = stagingMap[key];
}
}
}
}
} catch (error) {
console.error("Failed to load staging setting from secureStore:", error);
}
};
// Initialize environment settings
// We use an IIFE to handle the async initialization
(async () => {
try {
await initializeEnv();
} catch (error) {
console.error("Failed to initialize environment settings:", error);
}
})();
export default env; export default env;
// +1 // +1

View file

@ -2,7 +2,11 @@ import { useEffect, useRef, useState } from "react";
import { createLogger } from "~/lib/logger"; import { createLogger } from "~/lib/logger";
import { BACKGROUND_SCOPES } from "~/lib/logger/scopes"; import { BACKGROUND_SCOPES } from "~/lib/logger/scopes";
import { usePermissionWizardState, usePermissionsState } from "~/stores"; import {
usePermissionWizardState,
usePermissionsState,
useTreeState,
} from "~/stores";
import trackLocation from "~/location/trackLocation"; import trackLocation from "~/location/trackLocation";
@ -12,6 +16,8 @@ const locationLogger = createLogger({
}); });
export default function useTrackLocation() { export default function useTrackLocation() {
const { splashScreenHidden } = useTreeState(["splashScreenHidden"]);
const { currentStep, completed } = usePermissionWizardState([ const { currentStep, completed } = usePermissionWizardState([
"completed", "completed",
"currentStep", "currentStep",
@ -34,7 +40,8 @@ export default function useTrackLocation() {
if ( if (
locationBackground && locationBackground &&
motion && motion &&
(currentStep === "tracking" || currentStep === "success" || completed) (currentStep === "tracking" || currentStep === "success" || completed) &&
splashScreenHidden
) { ) {
locationLogger.info("Enabling location tracking", { locationLogger.info("Enabling location tracking", {
step: currentStep, step: currentStep,
@ -48,7 +55,7 @@ export default function useTrackLocation() {
step: currentStep, step: currentStep,
}); });
} }
}, [locationBackground, motion, currentStep, completed]); }, [locationBackground, motion, currentStep, completed, splashScreenHidden]);
useEffect(() => { useEffect(() => {
if (trackLocationEnabled) { if (trackLocationEnabled) {

View file

@ -9,6 +9,8 @@ import LayoutProviders from "~/layout/LayoutProviders";
import loadRessources from "~/layout/loadRessources"; import loadRessources from "~/layout/loadRessources";
import useMount from "~/hooks/useMount"; import useMount from "~/hooks/useMount";
import { treeActions } from "~/stores";
SplashScreen.preventAutoHideAsync(); SplashScreen.preventAutoHideAsync();
export default function AppView() { export default function AppView() {
@ -30,6 +32,7 @@ export default function AppView() {
const onLayoutRootView = useCallback(async () => { const onLayoutRootView = useCallback(async () => {
if (appIsReady) { if (appIsReady) {
await SplashScreen.hideAsync(); await SplashScreen.hideAsync();
treeActions.splashScreenHidden();
} }
}, [appIsReady]); }, [appIsReady]);

View file

@ -31,3 +31,12 @@ export const LOG_LEVEL_PRIORITY = {
[LOG_LEVELS.WARN]: 2, [LOG_LEVELS.WARN]: 2,
[LOG_LEVELS.ERROR]: 3, [LOG_LEVELS.ERROR]: 3,
}; };
// Function to update the minimum log level
export const setMinLogLevel = (level) => {
if (LOG_LEVELS[level] || Object.values(LOG_LEVELS).includes(level)) {
config.minLevel = level;
return true;
}
return false;
};

View file

@ -93,4 +93,4 @@ export const logger = new Logger();
export const createLogger = (scopes) => logger.withScopes(scopes); export const createLogger = (scopes) => logger.withScopes(scopes);
// Export types and config for external use // Export types and config for external use
export { LOG_LEVELS } from "./config"; export { LOG_LEVELS, setMinLogLevel } from "./config";

View file

@ -0,0 +1,110 @@
import BackgroundGeolocation from "react-native-background-geolocation";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { createLogger } from "~/lib/logger";
import { BACKGROUND_SCOPES } from "~/lib/logger/scopes";
const EMULATOR_MODE_KEY = "emulator_mode_enabled";
// Global variables
let emulatorIntervalId = null;
let isEmulatorModeEnabled = false;
// Create a logger for the emulator service
const emulatorLogger = createLogger({
module: BACKGROUND_SCOPES.GEOLOCATION,
feature: "emulator",
});
// Initialize emulator mode based on stored preference
export const initEmulatorMode = async () => {
try {
const storedValue = await AsyncStorage.getItem(EMULATOR_MODE_KEY);
emulatorLogger.debug("Initializing emulator mode", { storedValue });
if (storedValue === "true") {
await enableEmulatorMode();
}
} catch (error) {
emulatorLogger.error("Failed to initialize emulator mode", {
error: error.message,
stack: error.stack,
});
}
};
// Enable emulator mode
export const enableEmulatorMode = async () => {
emulatorLogger.info("Enabling emulator mode");
// Clear existing interval if any
if (emulatorIntervalId) {
clearInterval(emulatorIntervalId);
}
try {
// Call immediately once
await BackgroundGeolocation.changePace(true);
emulatorLogger.debug("Initial changePace call successful");
// Then set up interval
emulatorIntervalId = setInterval(
() => {
BackgroundGeolocation.changePace(true);
emulatorLogger.debug("Interval changePace call executed");
},
30 * 60 * 1000,
); // 30 minutes
isEmulatorModeEnabled = true;
// Persist the setting
await AsyncStorage.setItem(EMULATOR_MODE_KEY, "true");
emulatorLogger.debug("Emulator mode setting saved");
} catch (error) {
emulatorLogger.error("Failed to enable emulator mode", {
error: error.message,
stack: error.stack,
});
}
};
// Disable emulator mode
export const disableEmulatorMode = async () => {
emulatorLogger.info("Disabling emulator mode");
if (emulatorIntervalId) {
clearInterval(emulatorIntervalId);
emulatorIntervalId = null;
}
isEmulatorModeEnabled = false;
// Persist the setting
try {
await AsyncStorage.setItem(EMULATOR_MODE_KEY, "false");
emulatorLogger.debug("Emulator mode setting saved");
} catch (error) {
emulatorLogger.error("Failed to save emulator mode setting", {
error: error.message,
stack: error.stack,
});
}
};
// Get current emulator mode state
export const getEmulatorModeState = () => {
return isEmulatorModeEnabled;
};
// Toggle emulator mode
export const toggleEmulatorMode = async (enabled) => {
emulatorLogger.info("Toggling emulator mode", { enabled });
if (enabled) {
await enableEmulatorMode();
} else {
await disableEmulatorMode();
}
return isEmulatorModeEnabled;
};

View file

@ -3,6 +3,9 @@ import { TRACK_MOVE } from "~/misc/devicePrefs";
import { createLogger } from "~/lib/logger"; import { createLogger } from "~/lib/logger";
import { BACKGROUND_SCOPES } from "~/lib/logger/scopes"; import { BACKGROUND_SCOPES } from "~/lib/logger/scopes";
import jwtDecode from "jwt-decode"; import jwtDecode from "jwt-decode";
import { initEmulatorMode } from "./emulatorService";
import throttle from "lodash.throttle";
import { import {
getAuthState, getAuthState,
@ -64,6 +67,16 @@ export default async function trackLocation() {
feature: "tracking", feature: "tracking",
}); });
// Log the geolocation sync URL for debugging
locationLogger.info("Geolocation sync URL configuration", {
url: env.GEOLOC_SYNC_URL,
isStaging: env.IS_STAGING,
});
// Throttling configuration for auth reload only
const AUTH_RELOAD_THROTTLE = 5000; // 5 seconds throttle
// Handle auth function - no throttling or cooldown
async function handleAuth(userToken) { async function handleAuth(userToken) {
locationLogger.info("Handling auth token update", { locationLogger.info("Handling auth token update", {
hasToken: !!userToken, hasToken: !!userToken,
@ -77,10 +90,40 @@ export default async function trackLocation() {
// unsub(); // unsub();
locationLogger.debug("Updating background geolocation config"); locationLogger.debug("Updating background geolocation config");
await BackgroundGeolocation.setConfig({ await BackgroundGeolocation.setConfig({
url: env.GEOLOC_SYNC_URL, // Update the sync URL for when it's changed for staging
headers: { headers: {
Authorization: `Bearer ${userToken}`, Authorization: `Bearer ${userToken}`,
}, },
}); });
// Log the authorization header that was set
locationLogger.debug(
"Set Authorization header for background geolocation",
{
headerSet: true,
tokenPrefix: userToken ? userToken.substring(0, 10) + "..." : null,
},
);
// Verify the current configuration
try {
const currentConfig = await BackgroundGeolocation.getConfig();
locationLogger.debug("Current background geolocation config", {
hasHeaders: !!currentConfig.headers,
headerKeys: currentConfig.headers
? Object.keys(currentConfig.headers)
: [],
authHeader: currentConfig.headers?.Authorization
? currentConfig.headers.Authorization.substring(0, 15) + "..."
: "Not set",
url: currentConfig.url,
});
} catch (error) {
locationLogger.error("Failed to get background geolocation config", {
error: error.message,
});
}
const state = await BackgroundGeolocation.getState(); const state = await BackgroundGeolocation.getState();
try { try {
const decodedToken = jwtDecode(userToken); const decodedToken = jwtDecode(userToken);
@ -135,7 +178,20 @@ export default async function trackLocation() {
} }
}); });
// The core auth reload function that will be throttled
function _reloadAuth() {
locationLogger.info("Refreshing authentication token");
authActions.reload(); // should retriger sync in handleAuth via subscribeAuthState when done
}
// Create throttled version of auth reload with lodash
const reloadAuth = throttle(_reloadAuth, AUTH_RELOAD_THROTTLE, {
leading: true,
trailing: true,
});
BackgroundGeolocation.onHttp((response) => { BackgroundGeolocation.onHttp((response) => {
// Log the full response including headers if available
locationLogger.debug("HTTP response received", { locationLogger.debug("HTTP response received", {
status: response?.status, status: response?.status,
success: response?.success, success: response?.success,
@ -143,15 +199,48 @@ export default async function trackLocation() {
url: response?.url, url: response?.url,
method: response?.method, method: response?.method,
isSync: response?.isSync, isSync: response?.isSync,
requestHeaders:
response?.request?.headers || "Headers not available in response",
}); });
// Log the current auth token for comparison
const { userToken } = getAuthState();
locationLogger.debug("Current auth state token", {
tokenAvailable: !!userToken,
tokenPrefix: userToken ? userToken.substring(0, 10) + "..." : null,
});
const statusCode = response?.status; const statusCode = response?.status;
switch (statusCode) { switch (statusCode) {
case 410: case 410:
// Token expired, logout
locationLogger.info("Auth token expired (410), logging out");
authActions.logout(); authActions.logout();
break; break;
case 401: case 401:
locationLogger.info("Refreshing authentication token"); // Unauthorized, use throttled reload
authActions.reload(); // should retriger sync in handleAuth via subscribeAuthState when done locationLogger.info("Unauthorized (401), attempting to refresh token");
// Add more detailed logging of the error response
try {
const errorBody = response?.responseText
? JSON.parse(response.responseText)
: null;
locationLogger.debug("Unauthorized error details", {
errorBody,
errorType: errorBody?.error?.type,
errorMessage: errorBody?.error?.message,
errorPath: errorBody?.error?.errors?.[0]?.path,
});
} catch (e) {
locationLogger.debug("Failed to parse error response", {
error: e.message,
responseText: response?.responseText,
});
}
reloadAuth();
break; break;
} }
}); });
@ -205,10 +294,14 @@ export default async function trackLocation() {
if (count > 0) { if (count > 0) {
locationLogger.info(`Found ${count} pending records, forcing sync`); locationLogger.info(`Found ${count} pending records, forcing sync`);
try { try {
const records = await BackgroundGeolocation.sync(); const { userToken } = getAuthState();
locationLogger.debug("Forced sync result", { const state = await BackgroundGeolocation.getState();
recordsCount: records?.length || 0, if (userToken && state.enabled) {
}); const records = await BackgroundGeolocation.sync();
locationLogger.debug("Forced sync result", {
recordsCount: records?.length || 0,
});
}
} catch (error) { } catch (error) {
locationLogger.error("Forced sync failed", { locationLogger.error("Forced sync failed", {
error: error, error: error,
@ -231,4 +324,7 @@ export default async function trackLocation() {
// Check for pending records after a short delay to ensure everything is initialized // Check for pending records after a short delay to ensure everything is initialized
setTimeout(checkPendingRecords, 5000); setTimeout(checkPendingRecords, 5000);
// Initialize emulator mode if previously enabled
initEmulatorMode();
} }

View file

@ -1,5 +1,5 @@
// related to services/tasks/src/geocode/config.js // related to services/tasks/src/geocode/config.js
export const TRACK_MOVE = 100; export const TRACK_MOVE = 10;
export const DEFAULT_DEVICE_RADIUS_ALL = 500; export const DEFAULT_DEVICE_RADIUS_ALL = 500;
export const DEFAULT_DEVICE_RADIUS_REACH = 25000; export const DEFAULT_DEVICE_RADIUS_REACH = 25000;
export const MAX_BASEUSER_DEVICE_TRACKING = 25000; export const MAX_BASEUSER_DEVICE_TRACKING = 25000;

View file

@ -21,6 +21,7 @@ import Relatives from "~/scenes/Relatives";
import Sheets from "~/scenes/Sheets"; import Sheets from "~/scenes/Sheets";
import AlertAggListArchived from "~/scenes/AlertAggListArchived"; import AlertAggListArchived from "~/scenes/AlertAggListArchived";
import About from "~/scenes/About"; import About from "~/scenes/About";
import Contribute from "~/scenes/Contribute";
import Location from "~/scenes/Location"; import Location from "~/scenes/Location";
import Developer from "~/scenes/Developer"; import Developer from "~/scenes/Developer";
import HelpSignal from "~/scenes/HelpSignal"; import HelpSignal from "~/scenes/HelpSignal";
@ -413,6 +414,22 @@ export default React.memo(function DrawerNav() {
}} }}
listeners={{}} listeners={{}}
/> />
<Drawer.Screen
name="Contribute"
component={Contribute}
options={{
drawerLabel: "Faire un don",
drawerIcon: ({ focused }) => (
<MaterialCommunityIcons
name="hand-heart"
{...iconProps}
{...(focused ? iconFocusedProps : {})}
/>
),
unmountOnBlur: true,
}}
listeners={{}}
/>
<Drawer.Screen <Drawer.Screen
name="Sheets" name="Sheets"
component={Sheets} component={Sheets}

View file

@ -253,6 +253,12 @@ export default function HeaderRight(props) {
}} }}
/> />
<Divider /> <Divider />
<Menu.Item
title="Faire un don"
onPress={() => {
navigateTo({ name: "Contribute" });
}}
/>
<Menu.Item <Menu.Item
title="À Propos" title="À Propos"
onPress={() => { onPress={() => {

View file

@ -39,6 +39,8 @@ function getHeaderTitle(route) {
return "Alertes archivées"; return "Alertes archivées";
case "About": case "About":
return "À Propos"; return "À Propos";
case "Contribute":
return "Faire un don";
case "Location": case "Location":
return "Ma Localisation"; return "Ma Localisation";
case "NotFound": case "NotFound":

View file

@ -13,15 +13,17 @@ export default function createAuthLink({ store }) {
const { getAuthState } = store; const { getAuthState } = store;
const authLink = new ApolloLink((operation, forward) => { const authLink = new ApolloLink((operation, forward) => {
const { userToken } = getAuthState(); const context = operation.getContext();
const headers = operation.getContext().hasOwnProperty("headers") const headers = context.hasOwnProperty("headers") ? context.headers : {};
? operation.getContext().headers
: {}; // Skip adding auth header if skipAuth flag is set
if (userToken && headers["X-Hasura-Role"] !== "anonymous") { if (!context.skipAuth) {
setBearerHeader(headers, userToken); const { userToken } = getAuthState();
} else { if (userToken) {
headers["X-Hasura-Role"] = "anonymous"; setBearerHeader(headers, userToken);
}
} }
// authLinkLogger.debug("Request headers", { headers }); // authLinkLogger.debug("Request headers", { headers });
operation.setContext({ headers }); operation.setContext({ headers });
return forward(operation); return forward(operation);

View file

@ -1,12 +1,20 @@
import React, { useState, useCallback, useEffect } from "react"; import React, { useState, useCallback, useEffect } from "react";
import { View, Image, ScrollView } from "react-native"; import {
View,
Image,
ScrollView,
TouchableOpacity,
Linking,
Alert,
} from "react-native";
import { Button } from "react-native-paper"; import { Button } from "react-native-paper";
import { useNavigation } from "@react-navigation/native";
import * as Updates from "expo-updates"; import * as Updates from "expo-updates";
import { StatusBar } from "expo-status-bar"; import { StatusBar } from "expo-status-bar";
import { MaterialIcons, AntDesign, FontAwesome } from "@expo/vector-icons"; import { MaterialIcons, AntDesign, FontAwesome } from "@expo/vector-icons";
import Text from "~/components/Text"; import Text from "~/components/Text";
import { useTheme } from "~/theme"; import { useTheme, createStyles } from "~/theme";
import { paramsActions, useParamsState } from "~/stores"; import { paramsActions, useParamsState } from "~/stores";
@ -16,7 +24,24 @@ const logo = require("~/assets/img/logo192.png");
const version = require("../../../package.json").version; const version = require("../../../package.json").version;
const openURL = async (url) => {
try {
const supported = await Linking.canOpenURL(url);
if (supported) {
await Linking.openURL(url);
} else {
Alert.alert("Erreur", "Impossible d'ouvrir ce lien");
}
} catch (error) {
Alert.alert(
"Erreur",
"Une erreur s'est produite lors de l'ouverture du lien",
);
}
};
export default function About() { export default function About() {
const navigation = useNavigation();
const textStyle = { const textStyle = {
textAlign: "left", textAlign: "left",
fontSize: 16, fontSize: 16,
@ -153,6 +178,81 @@ export default function About() {
</Text> </Text>
</View> </View>
</View> </View>
{/* Website and Contribute Buttons */}
<View style={{ paddingHorizontal: 15, paddingVertical: 10 }}>
{/* Website Button */}
<TouchableOpacity
style={{
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.surface,
borderRadius: 8,
paddingVertical: 12,
paddingHorizontal: 16,
marginBottom: 10,
shadowColor: colors.shadow,
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
}}
onPress={() => openURL("https://alerte-secours.fr")}
>
<MaterialIcons
name="language"
size={24}
color={colors.primary}
style={{ marginRight: 10 }}
/>
<Text
style={{
color: colors.onSurface,
fontSize: 16,
fontWeight: "bold",
marginLeft: 5,
}}
>
Site officiel
</Text>
</TouchableOpacity>
{/* Contribute Button */}
<TouchableOpacity
style={{
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.surface,
borderRadius: 8,
paddingVertical: 12,
paddingHorizontal: 16,
shadowColor: colors.shadow,
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
}}
onPress={() => navigation.navigate("Contribute")}
>
<MaterialIcons
name="favorite"
size={24}
color={colors.primary}
style={{ marginRight: 10 }}
/>
<Text
style={{
color: colors.onSurface,
fontSize: 16,
fontWeight: "bold",
marginLeft: 5,
}}
>
Contribuer au projet
</Text>
</TouchableOpacity>
</View>
<View style={{ padding: 15, justifyContent: "center" }}> <View style={{ padding: 15, justifyContent: "center" }}>
<Button <Button
mode="text" mode="text"

View file

@ -3,6 +3,7 @@ import { getDistance } from "geolib";
import Supercluster from "supercluster"; import Supercluster from "supercluster";
import useShallowMemo from "~/hooks/useShallowMemo"; import useShallowMemo from "~/hooks/useShallowMemo";
import useShallowEffect from "~/hooks/useShallowEffect"; import useShallowEffect from "~/hooks/useShallowEffect";
import { deepEqual } from "fast-equals";
export default function useFeatures({ export default function useFeatures({
clusterFeature, clusterFeature,
@ -38,35 +39,67 @@ export default function useFeatures({
return computedList; return computedList;
}, [alertingList, userCoords, hasUserCoords]); }, [alertingList, userCoords, hasUserCoords]);
const featureCollection = useShallowMemo( const featureCollection = useShallowMemo(() => {
() => ({ const features = list.map((row) => {
type: "FeatureCollection", const { alert } = row;
features: list.map((row) => { const { level, state } = alert;
const { alert } = row; const [longitude, latitude] = alert.location.coordinates;
const { level, state } = alert; const coordinates = [longitude, latitude];
const [longitude, latitude] = alert.location.coordinates; const id = `alert:${alert.id}`;
const icon = state === "open" ? level : `${level}Disabled`;
return {
type: "Feature",
id,
properties: {
id,
level,
icon,
alert,
coordinates,
},
geometry: {
type: "Point",
coordinates,
},
};
});
// Add initial location marker if locations are different
list.forEach((row) => {
const { alert } = row;
if (
alert.initialLocation &&
alert.location &&
!deepEqual(alert.initialLocation, alert.location)
) {
const [longitude, latitude] = alert.initialLocation.coordinates;
const coordinates = [longitude, latitude]; const coordinates = [longitude, latitude];
const id = `alert:${alert.id}`; const id = `alert:${alert.id}:initial`;
const icon = state === "open" ? level : `${level}Disabled`;
return { features.push({
type: "Feature", type: "Feature",
id, id,
properties: { properties: {
id, id,
level, icon: "origin",
icon, level: alert.level,
alert, alert,
coordinates, coordinates,
isInitialLocation: true,
}, },
geometry: { geometry: {
type: "Point", type: "Point",
coordinates, coordinates,
}, },
}; });
}), }
}), });
[list],
); return {
type: "FeatureCollection",
features,
};
}, [list]);
const superCluster = useShallowMemo(() => { const superCluster = useShallowMemo(() => {
const cluster = new Supercluster({ radius: 40, maxZoom: 16 }); const cluster = new Supercluster({ radius: 40, maxZoom: 16 });

View file

@ -50,7 +50,9 @@ function Chat() {
name="information-outline" name="information-outline"
style={styles.resolvedLabelImage} style={styles.resolvedLabelImage}
/> />
<Text style={styles.resolvedLabelText}>Cette alerte est résolue</Text> <Text style={styles.resolvedLabelText}>
Cette alerte est terminée
</Text>
</View> </View>
)} )}
{isArchived && ( {isArchived && (

View file

@ -0,0 +1,232 @@
import { useCallback, useEffect, useState, useRef } from "react";
import { View, AppState } from "react-native";
import { Switch, Button } from "react-native-paper";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useMutation } from "@apollo/client";
import * as Location from "expo-location";
import { UPDATE_ALERT_FOLLOW_LOCATION_MUTATION } from "./gql";
import Text from "~/components/Text";
import { createStyles, useTheme } from "~/theme";
import { usePermissionsState, permissionsActions } from "~/stores";
import requestPermissionLocationBackground from "~/permissions/requestPermissionLocationBackground";
import requestPermissionLocationForeground from "~/permissions/requestPermissionLocationForeground";
import openSettings from "~/lib/native/openSettings";
export default function FieldFollowLocation({ alert }) {
const styles = useStyles();
const { colors } = useTheme();
const { id: alertId } = alert;
const [updateAlertFollowLocationMutation] = useMutation(
UPDATE_ALERT_FOLLOW_LOCATION_MUTATION,
);
const { locationForeground, locationBackground } = usePermissionsState([
"locationForeground",
"locationBackground",
]);
const [followLocation, setFollowLocation] = useState(
alert.followLocation || false,
);
const [isRequestingPermissions, setIsRequestingPermissions] = useState(false);
const prevFollowLocation = useRef(alert.followLocation);
useEffect(() => {
if (prevFollowLocation.current !== alert.followLocation) {
prevFollowLocation.current = alert.followLocation;
setFollowLocation(alert.followLocation || false);
}
}, [alert.followLocation]);
// Check current permission status
const checkPermissionStatus = useCallback(async () => {
try {
const { status: fgStatus } =
await Location.getForegroundPermissionsAsync();
const { status: bgStatus } =
await Location.getBackgroundPermissionsAsync();
permissionsActions.setLocationForeground(fgStatus === "granted");
permissionsActions.setLocationBackground(bgStatus === "granted");
} catch (error) {
console.error("Error checking location permissions:", error);
}
}, []);
// Check permissions on mount
useEffect(() => {
checkPermissionStatus();
}, [checkPermissionStatus]);
// Listen for app state changes to refresh permissions when returning from settings
useEffect(() => {
const handleAppStateChange = (nextAppState) => {
if (nextAppState === "active") {
// App came to foreground, check permissions
checkPermissionStatus();
}
};
const subscription = AppState.addEventListener(
"change",
handleAppStateChange,
);
return () => {
subscription?.remove();
};
}, [checkPermissionStatus]);
const requestLocationPermissions = useCallback(async () => {
setIsRequestingPermissions(true);
try {
// Request foreground permission first
const foregroundGranted = await requestPermissionLocationForeground();
permissionsActions.setLocationForeground(foregroundGranted);
if (foregroundGranted) {
// Request background permission if foreground is granted
const backgroundGranted = await requestPermissionLocationBackground();
permissionsActions.setLocationBackground(backgroundGranted);
return backgroundGranted;
}
return false;
} catch (error) {
console.error("Error requesting location permissions:", error);
return false;
} finally {
setIsRequestingPermissions(false);
}
}, []);
const toggleFollowLocation = useCallback(
async (value) => {
if (value) {
// Check if permissions are granted when enabling
if (!locationForeground || !locationBackground) {
const permissionsGranted = await requestLocationPermissions();
if (!permissionsGranted) {
// Don't enable if permissions weren't granted
return;
}
}
}
setFollowLocation(value);
updateAlertFollowLocationMutation({
variables: { alertId, followLocation: value },
});
},
[
alertId,
updateAlertFollowLocationMutation,
locationForeground,
locationBackground,
requestLocationPermissions,
],
);
const hasRequiredPermissions = locationForeground && locationBackground;
const showPermissionWarning = followLocation && !hasRequiredPermissions;
return (
<View style={styles.container}>
<View style={styles.content}>
<MaterialCommunityIcons
name="crosshairs-gps"
style={styles.icon}
size={20}
/>
<Text style={styles.label}>Suivre ma localisation</Text>
<Switch
value={followLocation}
onValueChange={toggleFollowLocation}
color={colors.primary}
disabled={isRequestingPermissions}
/>
</View>
{showPermissionWarning && (
<View style={styles.warningContainer}>
<View style={styles.warningContent}>
<MaterialCommunityIcons
name="alert-circle-outline"
style={styles.warningIcon}
size={16}
/>
<Text style={styles.warningText}>
Permissions de localisation requises
</Text>
</View>
<Button
mode="outlined"
onPress={openSettings}
style={styles.settingsButton}
labelStyle={styles.settingsButtonLabel}
compact
>
Paramétrer
</Button>
</View>
)}
</View>
);
}
const useStyles = createStyles(
({ wp, hp, scaleText, fontSize, theme: { colors } }) => ({
container: {
marginVertical: hp(1),
paddingHorizontal: wp(4),
paddingVertical: hp(1.5),
backgroundColor: colors.surface,
borderRadius: 8,
borderWidth: 1,
borderColor: colors.outline,
},
content: {
flexDirection: "row",
alignItems: "center",
},
icon: {
color: colors.primary,
marginRight: wp(2),
},
label: {
flex: 1,
...scaleText({ fontSize: 16 }),
color: colors.onSurface,
},
warningContainer: {
marginTop: hp(1),
paddingTop: hp(1),
borderTopWidth: 1,
borderTopColor: colors.outline,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
warningContent: {
flexDirection: "row",
alignItems: "center",
flex: 1,
},
warningIcon: {
color: colors.error,
marginRight: wp(1),
},
warningText: {
...scaleText({ fontSize: 14 }),
color: colors.error,
flex: 1,
},
settingsButton: {
marginLeft: wp(2),
},
settingsButtonLabel: {
...scaleText({ fontSize: 12 }),
},
}),
);

View file

@ -60,3 +60,17 @@ export const UPDATE_ALERT_SUBJECT_MUTATION = gql`
} }
} }
`; `;
export const UPDATE_ALERT_FOLLOW_LOCATION_MUTATION = gql`
mutation updateAlertFollowLocation(
$alertId: Int!
$followLocation: Boolean!
) {
updateOneAlert(
pk_columns: { id: $alertId }
_set: { followLocation: $followLocation }
) {
id
}
}
`;

View file

@ -1,8 +1,9 @@
import React, { useCallback, useState } from "react"; import React, { useCallback, useState, useMemo } from "react";
import { View, ImageBackground, ScrollView } from "react-native"; import { View, ImageBackground, ScrollView } from "react-native";
import { TouchableRipple, Button, Title } from "react-native-paper"; import { TouchableRipple, Button, Title } from "react-native-paper";
import { useIsFocused, useNavigation } from "@react-navigation/native"; import { useIsFocused, useNavigation } from "@react-navigation/native";
import { MaterialCommunityIcons } from "@expo/vector-icons"; import { MaterialCommunityIcons } from "@expo/vector-icons";
import { deepEqual } from "fast-equals";
import withConnectivity from "~/hoc/withConnectivity"; import withConnectivity from "~/hoc/withConnectivity";
@ -21,6 +22,7 @@ import alertBigButtonBgMessagesGrey from "~/assets/img/alert-big-button-bg-messa
import Text from "~/components/Text"; import Text from "~/components/Text";
import AlertInfoLineLevel from "~/containers/AlertInfoLines/Level"; import AlertInfoLineLevel from "~/containers/AlertInfoLines/Level";
import LocationInfoSection from "~/containers/LocationInfoSection";
import AlertInfoLineCode from "~/containers/AlertInfoLines/Code"; import AlertInfoLineCode from "~/containers/AlertInfoLines/Code";
import AlertInfoLineDistance from "~/containers/AlertInfoLines/Distance"; import AlertInfoLineDistance from "~/containers/AlertInfoLines/Distance";
import AlertInfoLineCreatedTime from "~/containers/AlertInfoLines/CreatedTime"; import AlertInfoLineCreatedTime from "~/containers/AlertInfoLines/CreatedTime";
@ -50,6 +52,7 @@ import { useMutation } from "@apollo/client";
import FieldLevel from "./FieldLevel"; import FieldLevel from "./FieldLevel";
import FieldSubject from "./FieldSubject"; import FieldSubject from "./FieldSubject";
import FieldFollowLocation from "./FieldFollowLocation";
import MapLinksButton from "~/containers/MapLinksButton"; import MapLinksButton from "~/containers/MapLinksButton";
import { useTheme } from "~/theme"; import { useTheme } from "~/theme";
@ -197,6 +200,14 @@ export default withConnectivity(
const [parentScrollEnabled, setParentScrollEnabled] = useState(true); const [parentScrollEnabled, setParentScrollEnabled] = useState(true);
const isLocationsDifferent = useMemo(() => {
return (
alert.initialLocation &&
alert.location &&
!deepEqual(alert.initialLocation, alert.location)
);
}, [alert.initialLocation, alert.location]);
// if (!isFocused) { // if (!isFocused) {
// return null; // return null;
// } // }
@ -497,6 +508,8 @@ export default withConnectivity(
alert={alert} alert={alert}
setParentScrollEnabled={setParentScrollEnabled} setParentScrollEnabled={setParentScrollEnabled}
/> />
<FieldFollowLocation alert={alert} />
</View> </View>
)} )}
@ -505,15 +518,49 @@ export default withConnectivity(
<AlertInfoLineLevel alert={alert} isFirst /> <AlertInfoLineLevel alert={alert} isFirst />
)} )}
<AlertInfoLineCode alert={alert} /> <AlertInfoLineCode alert={alert} />
<AlertInfoLineDistance alert={alert} /> {!isSent && <AlertInfoLineDistance alert={alert} />}
<AlertInfoLineCreatedTime alert={alert} /> <AlertInfoLineCreatedTime alert={alert} />
<AlertInfoLineClosedTime alert={alert} /> <AlertInfoLineClosedTime alert={alert} />
{(!isSent || !isOpen) && <AlertInfoLineSubject alert={alert} />} {(!isSent || !isOpen) && <AlertInfoLineSubject alert={alert} />}
<AlertInfoLineAddress alert={alert} />
<AlertInfoLineNear alert={alert} /> {useMemo(() => {
<AlertInfoLineW3w alert={alert} /> if (isLocationsDifferent) {
<AlertInfoLineRadius alert={alert} /> return (
<AlertInfoLineSentBy alert={alert} /> <>
<LocationInfoSection
title="Position initiale"
alert={alert}
styles={styles}
/>
<LocationInfoSection
title={
alert.followLocation
? "Position actuelle"
: "Dernière position connue"
}
alert={alert}
useLastLocation={true}
styles={styles}
/>
</>
);
} else {
return (
<>
<AlertInfoLineAddress alert={alert} />
<AlertInfoLineNear alert={alert} />
<AlertInfoLineW3w alert={alert} />
</>
);
}
}, [alert, styles, isLocationsDifferent])}
<View
style={isLocationsDifferent ? styles.locationSeparator : null}
>
<AlertInfoLineRadius alert={alert} />
<AlertInfoLineSentBy alert={alert} />
</View>
</View> </View>
</View> </View>
</ScrollView> </ScrollView>

View file

@ -153,4 +153,20 @@ export default createStyles(({ wp, hp, scaleText, theme: { colors } }) => ({
lineHeight: 18, lineHeight: 18,
textAlign: "center", textAlign: "center",
}, },
locationSectionTitle: {
backgroundColor: colors.background,
paddingVertical: 8,
paddingHorizontal: 10,
marginTop: 5,
},
locationTitle: {
fontSize: 16,
fontWeight: "bold",
color: colors.primary,
},
locationSeparator: {
marginTop: 15,
borderTopWidth: 5,
borderColor: colors.background,
},
})); }));

View file

@ -0,0 +1,282 @@
import React from "react";
import {
View,
ScrollView,
Linking,
Alert,
TouchableOpacity,
} from "react-native";
import { MaterialIcons, AntDesign } from "@expo/vector-icons";
import Text from "~/components/Text";
import { createStyles, useTheme } from "~/theme";
const openURL = async (url) => {
try {
const supported = await Linking.canOpenURL(url);
if (supported) {
await Linking.openURL(url);
} else {
Alert.alert("Erreur", "Impossible d'ouvrir ce lien");
}
} catch (error) {
Alert.alert(
"Erreur",
"Une erreur s'est produite lors de l'ouverture du lien",
);
}
};
export default function Contribute() {
const styles = useStyles();
const { colors } = useTheme();
return (
<ScrollView style={{ flex: 1 }}>
<View style={styles.container}>
{/* Header */}
<View style={styles.header}>
<MaterialIcons name="favorite" size={28} color={colors.primary} />
<Text style={styles.title}>Contribuer au projet</Text>
</View>
{/* Description */}
<Text style={styles.description}>
Alerte-Secours est une application mobile citoyenne, gratuite, sans
publicité ni exploitation de données.
{"\n\n"}
Si vous souhaitez contribuer à son développement, sa maintenance et
son indépendance :
</Text>
{/* Liberapay Button */}
<TouchableOpacity
style={[
styles.donationButton,
styles.buttonContent,
styles.liberapayButton,
]}
onPress={() => openURL("https://liberapay.com/alerte-secours")}
activeOpacity={0.8}
>
<View style={styles.iconContainer}>
<MaterialIcons name="circle" style={styles.iconDonation} />
</View>
<View style={styles.buttonTextContainer}>
<Text style={styles.buttonLabel}>Liberapay Soutien régulier</Text>
<Text style={styles.buttonDescription}>
Pour un soutien récurrent et engagé. Chaque don contribue à
assurer la stabilité du service sur le long terme.
</Text>
</View>
</TouchableOpacity>
{/* Buy Me a Coffee Button */}
<TouchableOpacity
style={[
styles.donationButton,
styles.buttonContent,
styles.buymeacoffeeButton,
]}
onPress={() => openURL("https://buymeacoffee.com/alertesecours")}
activeOpacity={0.8}
>
<View style={styles.iconContainer}>
<MaterialIcons name="local-cafe" style={styles.iconDonation} />
</View>
<View style={styles.buttonTextContainer}>
<Text style={styles.buttonLabel}>
Buy Me a Coffee Don ponctuel
</Text>
<Text style={styles.buttonDescription}>
Pour un coup de pouce ponctuel, un café virtuel pour encourager le
travail accompli !
</Text>
</View>
</TouchableOpacity>
{/* GitHub Sponsors Button */}
<TouchableOpacity
style={[
styles.donationButton,
styles.buttonContent,
styles.githubButton,
]}
onPress={() => openURL("https://github.com/sponsors/alerte-secours")}
activeOpacity={0.8}
>
<View style={styles.iconContainer}>
<AntDesign name="github" style={styles.iconDonation} />
</View>
<View style={styles.buttonTextContainer}>
<Text style={styles.buttonLabel}>GitHub Sponsors</Text>
<Text style={styles.buttonDescription}>
Pour les développeurs et utilisateurs de GitHub : soutenez le
projet directement via votre compte.
</Text>
</View>
</TouchableOpacity>
{/* Collaboration Section */}
<View style={styles.collaborationSection}>
<Text style={styles.collaborationTitle}>
Vous souhaitez nous rejoindre
</Text>
<Text style={styles.collaborationDescription}>
Ce projet fait sens pour vous et vous souhaitez vous engager dans un
projet d'avenir ?{"\n\n"}
Alerte-Secours est à la recherche de collaborateurs avec des
compétences dans le développement, la gestion de projet, le
business, la com, le graphisme. Contactez-nous si vous souhaitez
faire partie de l'aventure.
</Text>
<TouchableOpacity
style={styles.contactButton}
onPress={() => openURL("mailto:contact@alertesecours.fr")}
activeOpacity={0.8}
>
<MaterialIcons name="email" style={styles.contactIcon} />
<Text style={styles.contactText}>contact@alertesecours.fr</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
);
}
const useStyles = createStyles(({ theme: { colors, custom } }) => ({
container: {
flex: 1,
padding: 20,
},
header: {
flexDirection: "row",
alignItems: "center",
marginBottom: 24,
},
title: {
fontSize: 26,
fontWeight: "bold",
color: colors.primary,
marginLeft: 12,
},
description: {
fontSize: 16,
lineHeight: 26,
color: colors.onBackground,
marginBottom: 36,
textAlign: "left",
},
donationButton: {
marginVertical: 10,
borderRadius: 16,
elevation: 3,
shadowColor: colors.shadow,
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.15,
shadowRadius: 4,
},
buttonContent: {
minHeight: 64,
paddingVertical: 16,
paddingHorizontal: 20,
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
},
buttonLabel: {
fontSize: 17,
fontWeight: "700",
textAlign: "left",
marginBottom: 4,
color: custom.donation.onDonation,
},
buttonDescription: {
fontSize: 14,
opacity: 0.9,
textAlign: "left",
lineHeight: 20,
color: custom.donation.onDonation,
},
iconContainer: {
marginRight: 18,
width: 32,
height: 32,
alignItems: "center",
justifyContent: "center",
borderRadius: 16,
backgroundColor: "rgba(255, 255, 255, 0.2)",
},
buttonTextContainer: {
flex: 1,
alignItems: "flex-start",
},
// Icon styles
iconDonation: {
fontSize: 22,
color: custom.donation.onDonation,
},
// Button background styles
liberapayButton: {
backgroundColor: custom.donation.liberapay,
},
buymeacoffeeButton: {
backgroundColor: custom.donation.buymeacoffee,
},
githubButton: {
backgroundColor: custom.donation.github,
},
// Collaboration section styles
collaborationSection: {
marginTop: 40,
padding: 20,
backgroundColor: colors.surfaceVariant,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.outline,
},
collaborationTitle: {
fontSize: 22,
fontWeight: "bold",
color: colors.primary,
marginBottom: 16,
textAlign: "center",
},
collaborationDescription: {
fontSize: 16,
lineHeight: 24,
color: colors.onSurfaceVariant,
marginBottom: 20,
textAlign: "left",
},
contactButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
backgroundColor: colors.primary,
paddingVertical: 12,
paddingHorizontal: 20,
borderRadius: 12,
elevation: 2,
shadowColor: colors.shadow,
shadowOffset: {
width: 0,
height: 1,
},
shadowOpacity: 0.1,
shadowRadius: 2,
},
contactIcon: {
fontSize: 20,
color: colors.onPrimary,
marginRight: 8,
},
contactText: {
fontSize: 16,
fontWeight: "600",
color: colors.onPrimary,
},
}));

View file

@ -1,6 +1,7 @@
import React, { useState } from "react"; import React, { useState, useEffect } from "react";
import { View, StyleSheet, ScrollView } from "react-native"; import { View, StyleSheet, ScrollView } from "react-native";
import * as Sentry from "@sentry/react-native"; import * as Sentry from "@sentry/react-native";
import BackgroundGeolocation from "react-native-background-geolocation";
import { import {
Button, Button,
Card, Card,
@ -8,10 +9,18 @@ import {
Text, Text,
useTheme, useTheme,
Divider, Divider,
RadioButton,
} from "react-native-paper"; } from "react-native-paper";
import { createStyles } from "~/theme"; import { createStyles } from "~/theme";
import env, { setStaging } from "~/env"; import env, { setStaging } from "~/env";
import { authActions } from "~/stores"; import { authActions } from "~/stores";
import {
getEmulatorModeState,
toggleEmulatorMode as toggleEmulatorModeService,
initEmulatorMode,
} from "~/location/emulatorService";
import { LOG_LEVELS, setMinLogLevel } from "~/lib/logger";
import { config as loggerConfig } from "~/lib/logger/config";
const reset = async () => { const reset = async () => {
await authActions.logout(); await authActions.logout();
@ -29,7 +38,60 @@ const Section = ({ title, children }) => {
export default function Developer() { export default function Developer() {
const styles = useStyles(); const styles = useStyles();
const { colors } = useTheme();
const [isStaging, setIsStaging] = useState(env.IS_STAGING); const [isStaging, setIsStaging] = useState(env.IS_STAGING);
const [emulatorMode, setEmulatorMode] = useState(false);
const [syncStatus, setSyncStatus] = useState(null); // null, 'syncing', 'success', 'error'
const [syncResult, setSyncResult] = useState("");
const [logLevel, setLogLevel] = useState(LOG_LEVELS.DEBUG);
// Initialize emulator mode and log level when component mounts
useEffect(() => {
// Initialize the emulator service
initEmulatorMode();
// Set the initial state based on the global service
setEmulatorMode(getEmulatorModeState());
// Set the initial log level from config
setLogLevel(loggerConfig.minLevel);
}, []);
// Handle log level change
const handleLogLevelChange = (level) => {
setLogLevel(level);
setMinLogLevel(level);
};
// Handle toggling emulator mode
const handleEmulatorModeToggle = async (enabled) => {
const newState = await toggleEmulatorModeService(enabled);
setEmulatorMode(newState);
};
// Function to trigger geolocation sync
const triggerGeolocSync = async () => {
try {
setSyncStatus("syncing");
setSyncResult("");
// Get the count of pending records first
const count = await BackgroundGeolocation.getCount();
// Perform the sync
const records = await BackgroundGeolocation.sync();
const result = `Synced ${
records?.length || 0
} records (${count} pending)`;
setSyncResult(result);
setSyncStatus("success");
} catch (error) {
console.error("Geolocation sync failed:", error);
setSyncResult(`Sync failed: ${error.message}`);
setSyncStatus("error");
}
};
const triggerNullError = () => { const triggerNullError = () => {
try { try {
// Wrap the null error in try-catch // Wrap the null error in try-catch
@ -95,12 +157,46 @@ export default function Developer() {
<Switch <Switch
value={isStaging} value={isStaging}
onValueChange={async (value) => { onValueChange={async (value) => {
setStaging(value); setIsStaging(value); // Update UI immediately
setIsStaging(value); await setStaging(value); // Persist the change
await reset(); await reset(); // Reset auth state
}} }}
/> />
</View> </View>
<View style={styles.settingRow}>
<Text variant="bodyLarge">Emulator Mode</Text>
<Switch
value={emulatorMode}
onValueChange={handleEmulatorModeToggle}
/>
</View>
</Section>
<Section title="Logging Controls">
<Text variant="bodyLarge" style={styles.sectionLabel}>
Log Level
</Text>
<RadioButton.Group
onValueChange={handleLogLevelChange}
value={logLevel}
>
<View style={styles.radioRow}>
<RadioButton value={LOG_LEVELS.DEBUG} />
<Text variant="bodyMedium">DEBUG</Text>
</View>
<View style={styles.radioRow}>
<RadioButton value={LOG_LEVELS.INFO} />
<Text variant="bodyMedium">INFO</Text>
</View>
<View style={styles.radioRow}>
<RadioButton value={LOG_LEVELS.WARN} />
<Text variant="bodyMedium">WARN</Text>
</View>
<View style={styles.radioRow}>
<RadioButton value={LOG_LEVELS.ERROR} />
<Text variant="bodyMedium">ERROR</Text>
</View>
</RadioButton.Group>
</Section> </Section>
<Section title="Environment URLs"> <Section title="Environment URLs">
@ -150,6 +246,35 @@ export default function Developer() {
<Divider style={styles.divider} /> <Divider style={styles.divider} />
<Section title="Location Controls">
<Button
onPress={triggerGeolocSync}
style={styles.button}
contentStyle={styles.buttonContent}
labelStyle={styles.buttonLabel}
loading={syncStatus === "syncing"}
disabled={syncStatus === "syncing"}
>
Trigger Geolocation Sync
</Button>
{syncStatus && syncResult && (
<Text
style={[
styles.statusText,
{
color: syncStatus === "success" ? colors.primary : colors.error,
marginTop: 8,
},
]}
>
{syncResult}
</Text>
)}
</Section>
<Divider style={styles.divider} />
<Section title="Error Testing"> <Section title="Error Testing">
<Button <Button
onPress={triggerNullError} onPress={triggerNullError}
@ -254,4 +379,12 @@ const useStyles = createStyles(({ wp, hp, scaleText, theme: { colors } }) => ({
flex: 1, flex: 1,
flexWrap: "wrap", flexWrap: "wrap",
}, },
radioRow: {
flexDirection: "row",
alignItems: "center",
marginVertical: 2,
},
sectionLabel: {
marginBottom: 8,
},
})); }));

View file

@ -0,0 +1,58 @@
import React from "react";
import { TouchableOpacity, View } from "react-native";
import { useNavigation } from "@react-navigation/native";
import { createStyles } from "~/theme";
import Text from "~/components/Text";
import { MaterialIcons } from "@expo/vector-icons";
export default function ContributeButton() {
const navigation = useNavigation();
const styles = useStyles();
return (
<View>
<TouchableOpacity
style={styles.button}
onPress={() => navigation.navigate("Contribute")}
>
<MaterialIcons
name="favorite"
size={24}
color={styles.icon.color}
style={styles.icon}
/>
<Text style={styles.buttonText}>Contribuer au projet</Text>
</TouchableOpacity>
</View>
);
}
const useStyles = createStyles(({ theme: { colors } }) => ({
button: {
flexDirection: "row",
alignItems: "center",
alignSelf: "center",
marginTop: 20,
marginBottom: 10,
backgroundColor: colors.surface,
borderRadius: 8,
width: "100%",
paddingVertical: 12,
paddingHorizontal: 16,
shadowColor: colors.shadow,
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
},
buttonText: {
color: colors.onSurface,
fontSize: 16,
fontWeight: "bold",
marginLeft: 5,
},
icon: {
color: colors.primary,
marginRight: 10,
},
}));

View file

@ -12,6 +12,7 @@ import { createStyles, fontFamily } from "~/theme";
import HelpBlock from "./HelpBlock"; import HelpBlock from "./HelpBlock";
import RegisterRelativesButton from "./RegisterRelativesButton"; import RegisterRelativesButton from "./RegisterRelativesButton";
import NotificationsButton from "./NotificationsButton"; import NotificationsButton from "./NotificationsButton";
import ContributeButton from "./ContributeButton";
export default function SendAlert() { export default function SendAlert() {
const navigation = useNavigation(); const navigation = useNavigation();
@ -215,6 +216,8 @@ export default function SendAlert() {
{capitalize(levelLabel.call)} {capitalize(levelLabel.call)}
</Button> </Button>
</View> </View>
<ContributeButton />
</View> </View>
</ScrollView> </ScrollView>
); );

View file

@ -14,7 +14,7 @@ export default function SACFieldAlert(props) {
setValue("notifyRelatives", true); setValue("notifyRelatives", true);
break; break;
case "yellow": case "yellow":
setValue("callEmergency", false); setValue("callEmergency", true);
setValue("notifyRelatives", true); setValue("notifyRelatives", true);
break; break;
case "green": case "green":

View file

@ -22,6 +22,7 @@ export default function FieldNotifySelector() {
const callEmergency = watch("callEmergency"); const callEmergency = watch("callEmergency");
const notifyAround = watch("notifyAround"); const notifyAround = watch("notifyAround");
const notifyRelatives = watch("notifyRelatives"); const notifyRelatives = watch("notifyRelatives");
const followLocation = watch("followLocation");
const level = watch("level"); const level = watch("level");
const checkedColor = colors.primary; const checkedColor = colors.primary;
@ -106,6 +107,29 @@ export default function FieldNotifySelector() {
}} }}
/> />
</View> </View>
<View style={styles.followLocationContainer}>
<CheckboxItem
status={followLocation ? "checked" : "unchecked"}
style={styles.checkboxItem}
labelStyle={styles.checkboxLabel}
size={styleOptions.checkboxItem.size}
icon={() => (
<MaterialCommunityIcons
name="crosshairs-gps"
style={styles.checkboxIcon}
onPress={() => {
setValue("followLocation", !followLocation);
}}
/>
)}
color={checkedColor}
uncheckedColor={uncheckedColor}
label="Suivre ma localisation"
onPress={() => {
setValue("followLocation", !followLocation);
}}
/>
</View>
</View> </View>
); );
} }
@ -122,6 +146,7 @@ const useStyles = createStyles(
({ wp, hp, scaleText, fontSize, theme: { colors, textShadowForWhite } }) => ({ ({ wp, hp, scaleText, fontSize, theme: { colors, textShadowForWhite } }) => ({
container: { container: {
marginTop: hp(2), marginTop: hp(2),
marginBottom: hp(3),
}, },
checkboxItemContainer: { checkboxItemContainer: {
borderRadius: 4, borderRadius: 4,
@ -130,6 +155,13 @@ const useStyles = createStyles(
marginVertical: hp(0.2), marginVertical: hp(0.2),
backgroundColor: colors.surface, backgroundColor: colors.surface,
}, },
followLocationContainer: {
borderRadius: 4,
borderWidth: 1,
borderColor: colors.outline,
marginVertical: hp(4),
backgroundColor: colors.surface,
},
checkboxItem: { checkboxItem: {
paddingHorizontal: 6, paddingHorizontal: 6,
}, },

View file

@ -9,7 +9,8 @@ export default function SendAlertConfirm({ route }) {
const { alert } = params; const { alert } = params;
const level = alert?.level || params.level; const level = alert?.level || params.level;
const callEmergency = params.forceCallEmergency || level === "red"; const callEmergency =
params.forceCallEmergency || level === "red" || level === "yellow";
const methods = useForm({ const methods = useForm({
defaultValues: { defaultValues: {
@ -18,6 +19,7 @@ export default function SendAlertConfirm({ route }) {
callEmergency, callEmergency,
notifyAround: true, notifyAround: true,
notifyRelatives: true, notifyRelatives: true,
followLocation: true,
}, },
}); });

View file

@ -33,8 +33,14 @@ async function onSubmit(args, context) {
const [alertInput] = args; const [alertInput] = args;
const { navigation } = context; const { navigation } = context;
const { subject, level, callEmergency, notifyAround, notifyRelatives } = const {
alertInput; subject,
level,
callEmergency,
notifyAround,
notifyRelatives,
followLocation,
} = alertInput;
const coords = await getCurrentLocation(); const coords = await getCurrentLocation();
@ -62,6 +68,7 @@ async function onSubmit(args, context) {
callEmergency, callEmergency,
notifyAround, notifyAround,
notifyRelatives, notifyRelatives,
followLocation: !!followLocation,
location, location,
accuracy, accuracy,
altitude, altitude,

View file

@ -132,14 +132,43 @@ export default createAtom(({ get, merge, getActions }) => {
const reload = async () => { const reload = async () => {
authLogger.info("Reloading auth state"); authLogger.info("Reloading auth state");
// Check if we're already reloading or in a loading state
const { isReloading, lastReloadTime } = get();
const now = Date.now();
const timeSinceLastReload = now - lastReloadTime;
const RELOAD_COOLDOWN = 2000; // 2 seconds cooldown
if (isReloading) {
authLogger.info("Auth reload already in progress, skipping");
return true;
}
if (timeSinceLastReload < RELOAD_COOLDOWN) {
authLogger.info("Auth reload requested too soon, skipping", {
timeSinceLastReload,
cooldown: RELOAD_COOLDOWN,
});
return true;
}
if (isLoading()) { if (isLoading()) {
await loadingPromise; await loadingPromise;
return true; return true;
} }
startLoading();
await secureStore.deleteItemAsync("userToken"); // Set reloading state
await init(); merge({ isReloading: true, lastReloadTime: now });
return true;
try {
startLoading();
await secureStore.deleteItemAsync("userToken");
await init();
return true;
} finally {
// Clear reloading state even if there was an error
merge({ isReloading: false });
}
}; };
const onReload = async () => { const onReload = async () => {
@ -247,6 +276,8 @@ export default createAtom(({ get, merge, getActions }) => {
onReload: false, onReload: false,
onReloadAuthToken: null, onReloadAuthToken: null,
userOffMode: false, userOffMode: false,
isReloading: false,
lastReloadTime: 0,
}, },
actions: { actions: {
init, init,

View file

@ -49,15 +49,21 @@ export default createAtom(({ merge, getActions }) => {
merge({ suspend: true }); merge({ suspend: true });
}; };
const splashScreenHidden = () => {
merge({ splashScreenHidden: true });
};
return { return {
default: { default: {
triggerReload: false, triggerReload: false,
suspend: false, suspend: false,
splashScreenHidden: false,
}, },
actions: { actions: {
triggerReload, triggerReload,
suspendTree, suspendTree,
onReload, onReload,
splashScreenHidden,
}, },
}; };
}); });

View file

@ -96,6 +96,13 @@ const ThemeDark = {
call: "#4c6ef5", call: "#4c6ef5",
onColor: "#FFFFFF", onColor: "#FFFFFF",
}, },
donation: {
liberapay: "#f59f00", // Same as colors.warn
buymeacoffee: "#40c057", // Same as colors.ok
github: "#b3c4ff", // Same as colors.primary (dark theme)
onDonation: "#FFFFFF",
},
}, },
}; };
export default ThemeDark; export default ThemeDark;

View file

@ -95,6 +95,13 @@ const ThemeLight = {
call: "#4c6ef5", call: "#4c6ef5",
onColor: "#FFFFFF", onColor: "#FFFFFF",
}, },
donation: {
liberapay: "#f59f00", // Same as colors.warn
buymeacoffee: "#40c057", // Same as colors.ok
github: "#1864ab", // Same as colors.primary
onDonation: "#FFFFFF",
},
}, },
}; };