Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51565882ca | |||
| 229e0dd557 | |||
| 7da5acbba3 | |||
| 72f9400756 | |||
| 0493fcc939 | |||
| 44aaa32296 | |||
| bb286e1667 | |||
| 25676bf1e7 | |||
| 9c1f6ebf7e | |||
| b240d638ef | |||
| 2ac1777570 | |||
| 7018443b18 | |||
| 834025e560 | |||
| b3edf2c47b | |||
| 44fb492759 | |||
| ce1fa4972c | |||
| 9d6efdd43b | |||
| b3bba8cf38 | |||
| e52f2497e3 | |||
| 04e793cbb8 | |||
| 0501d66fdc | |||
| c221ef934a | |||
| 498958a27c | |||
| 09b5e7725d | |||
| bf252a9e9b | |||
| 6c7d57106c | |||
| d0e61ebdb0 | |||
| 9e461aab1a | |||
| de9a8606ab |
@@ -0,0 +1,19 @@
|
||||
# EditorConfig for TimeToLeave
|
||||
# https://editorconfig.org
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
max_line_length = 100
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
@@ -1,8 +1,21 @@
|
||||
# Server port
|
||||
PORT=3001
|
||||
|
||||
# Public deployment URL used for redirects and generated links
|
||||
DEPLOYMENT_URL=https://timetoleave.app
|
||||
|
||||
# ÖBB HAFAS API
|
||||
HAFAS_URL=https://fahrplan.oebb.at/bin/mgate.exe
|
||||
HAFAS_TIMEOUT_MS=10000
|
||||
HAFAS_VER=1.36
|
||||
HAFAS_LANG=eng
|
||||
HAFAS_AID=hf7mcf9bv3nv8g5f
|
||||
HAFAS_CLIENT_ID=OEBB
|
||||
HAFAS_CLIENT_VER=6020700
|
||||
HAFAS_CLIENT_NAME=oebbApp
|
||||
|
||||
# Optional ÖBB GTFS enrichment
|
||||
OEBB_GTFS_URL=https://static.web.oebb.at/open-data/soll-fahrplan-gtfs/GTFS_Fahrplan_2026.zip
|
||||
|
||||
# Nominatim geocoding (OpenStreetMap)
|
||||
NOMINATIM_URL=https://nominatim.openstreetmap.org
|
||||
@@ -19,5 +32,17 @@ WIENER_LINIEN_API_URL=https://api.wienerlinien.at/darwin-v2
|
||||
# CORS Configuration
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001,https://timetoleave.app
|
||||
|
||||
# Deployment URL
|
||||
DEPLOYMENT_URL=https://timetoleave.app
|
||||
# API rate limiting
|
||||
API_RATE_LIMIT_MAX_REQUESTS=120
|
||||
API_RATE_LIMIT_WINDOW_MS=60000
|
||||
|
||||
# Google Calendar OAuth (required for web Google Calendar sync)
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_REDIRECT_URI=http://localhost:3000/api/auth/google/callback
|
||||
|
||||
# Version returned by /api/health
|
||||
APP_VERSION=0.1.0
|
||||
|
||||
# Mobile app backend URL for physical device builds
|
||||
EXPO_PUBLIC_API_BASE_URL=http://localhost:3000
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
> Why do I have a folder named ".expo" in my project?
|
||||
|
||||
The ".expo" folder is created when an Expo project is started using "expo start" command.
|
||||
|
||||
> What do the files contain?
|
||||
|
||||
- "devices.json": contains information about devices that have recently opened this project. This is used to populate the "Development sessions" list in your development builds.
|
||||
- "settings.json": contains the server configuration that is used to serve the application manifest.
|
||||
|
||||
> Should I commit the ".expo" folder?
|
||||
|
||||
No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine.
|
||||
Upon project creation, the ".expo" folder is already added to your ".gitignore" file.
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"devices": []
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
jobs:
|
||||
lint-typecheck-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Build web
|
||||
run: npm run build
|
||||
env:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
@@ -49,3 +49,5 @@ runs/
|
||||
|
||||
# next.js build output (apps)
|
||||
apps/web/.next/
|
||||
|
||||
apps/mobile/log.txt
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
@@ -4,6 +4,41 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Mobile Application
|
||||
|
||||
- Default to dark theme across the mobile app
|
||||
- Filter native calendar events by location to exclude empty ones
|
||||
- Async station selection with error handling and alerts
|
||||
- Improved notification settings UI
|
||||
- Native calendar source selection with CalDAV/DAVx, Apple, Google, Exchange, subscribed, local, CardDAV, ActiveSync, and other source labels
|
||||
- Event detail sections split into reusable header, journey, bike, and nearby-stop components
|
||||
|
||||
### Calendar Integration
|
||||
|
||||
- **Google Calendar sync** via full OAuth 2.0 flow (token exchange, refresh, and status checks)
|
||||
- UI for connecting, syncing, and disconnecting Google accounts in the Calendar panel
|
||||
- Batch edit panel for managing event destinations on the web calendar
|
||||
- Edit support in AddEventModal for modifying existing events
|
||||
|
||||
### Public Transport
|
||||
|
||||
- HAFAS LocMatch method in API client for finding nearest station to current location
|
||||
- Improved station selection with async search and error handling
|
||||
- WienerLinien departures hook improvements
|
||||
- HAFAS response enrichment through the ÖBB GTFS fallback when available
|
||||
- Arrive-by journey search with fallback search window
|
||||
|
||||
### API Client
|
||||
|
||||
- Added `findStationByExtId` for resolving a saved station ID
|
||||
- Added `findNearestStationByCoords` for geolocation-based station lookup
|
||||
- Added base URL failover for backend availability issues
|
||||
- Enhanced HAFAS request handling
|
||||
|
||||
### Documentation
|
||||
|
||||
- Updated README, development, architecture, API, user, privacy, and testing documentation to match the current route tree and app behavior
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] — 2025-01-27
|
||||
@@ -42,7 +77,7 @@ your calendar with real-time public transport data to tell you exactly when to l
|
||||
|
||||
### Routing
|
||||
|
||||
- Bike route calculation from origin to departure station via OSRM
|
||||
- Bike route calculation and final walking route calculation via OSRM
|
||||
- Geocoding API integration for station lookups
|
||||
- Fallback and caching logic for API failures
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
| # | Step | ✅ | ✔️ |
|
||||
|---|---|----|-|
|
||||
| 19 | Add local notifications (expo-notifications) | [x] | [x] |
|
||||
| 20 | Add native calendar import (post-MVP) | [~] | [~] |
|
||||
| 20 | Add native calendar import with calendar selection | [x] | [x] |
|
||||
| 21 | Add mobile tests (core + API + store + screens) | [x] | [x] |
|
||||
| 22 | Prepare deployment (web backend + EAS mobile) | [x] | [x] |
|
||||
| 23 | Release MVP (verify acceptance criteria) | [x] | [x] |
|
||||
|
||||
@@ -1,54 +1,57 @@
|
||||
# TimeToLeave — Features Implementation Checklist
|
||||
# TimeToLeave - Features Implementation Checklist
|
||||
|
||||
## Phase 1 — New Settings Infrastructure (Steps 1-3)
|
||||
This checklist tracks the route-planning and settings features currently implemented across the web and shared packages.
|
||||
|
||||
| # | Step | ✅ | ✔️ |
|
||||
|---|---|----|----|
|
||||
| 1 | Extend ReminderSettings type with 3 new fields | [x] | [x] |
|
||||
| 2 | Update useReminderSettings hook with defaults + setters | [x] | [x] |
|
||||
| 3 | Update ReminderSettingsPanel UI (slider + 2 toggles) | [x] | [x] |
|
||||
## Settings Infrastructure
|
||||
|
||||
## Phase 2 — Walk Routing Infrastructure (Steps 4-6)
|
||||
| # | Step | Done | Verified |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Extend `ReminderSettings` with arrival buffer, walking visibility, and bike visibility | [x] | [x] |
|
||||
| 2 | Persist reminder settings in web `localStorage` and mobile `AsyncStorage` | [x] | [x] |
|
||||
| 3 | Add settings UI for reminder buffer, arrival buffer, notifications, walking, and bike options | [x] | [x] |
|
||||
|
||||
| # | Step | ✅ | ✔️ |
|
||||
|---|---|----|----|
|
||||
| 4 | Create WalkRoutingClient (OSRM foot profile) | [x] | [x] |
|
||||
| 5 | Create /api/walk-route endpoint | [x] | [x] |
|
||||
| 6 | Add getWalkRoute to api-client package | [x] | [x] |
|
||||
## Routing Infrastructure
|
||||
|
||||
## Phase 3 — Departure Time Calculation (Steps 7-8)
|
||||
| # | Step | Done | Verified |
|
||||
| --- | --- | --- | --- |
|
||||
| 4 | Add OSRM walking client and `/api/walk-route` endpoint | [x] | [x] |
|
||||
| 5 | Add OSRM bike client and `/api/bike-route` endpoint | [x] | [x] |
|
||||
| 6 | Add `getWalkRoute()` and `getBikeRoute()` to `@timetoleave/api-client` | [x] | [x] |
|
||||
| 7 | Add web/mobile hooks for walking and bike routes | [x] | [x] |
|
||||
|
||||
| # | Step | ✅ | ✔️ |
|
||||
|---|---|----|----|
|
||||
| 7 | Create useDepartureTime hook | [x] | [x] |
|
||||
| 8 | Update useClock to accept departureTime override | [x] | [x] |
|
||||
## Departure Calculation
|
||||
|
||||
## Phase 4 — Mode Selector & EventCard Updates (Steps 9-11)
|
||||
| # | Step | Done | Verified |
|
||||
| --- | --- | --- | --- |
|
||||
| 8 | Calculate leave-by time from selected transport mode | [x] | [x] |
|
||||
| 9 | Account for final walking time before choosing train journeys | [x] | [x] |
|
||||
| 10 | Support HAFAS arrive-by journey search with fallback window | [x] | [x] |
|
||||
| 11 | Update countdown logic to use computed departure time | [x] | [x] |
|
||||
|
||||
| # | Step | ✅ | ✔️ |
|
||||
|---|---|----|----|
|
||||
| 9 | Create useWalkRoute hook | [x] | [x] |
|
||||
| 10 | Create WalkingOption component | [x] | [x] |
|
||||
| 11 | Update EventCard with mode selector + conditional rendering | [x] | [x] |
|
||||
## Calendar and Event Management
|
||||
|
||||
## Phase 5 — TrainSection & JourneyList Updates (Steps 12-13)
|
||||
| # | Step | Done | Verified |
|
||||
| --- | --- | --- | --- |
|
||||
| 12 | Import web calendars from URL and local ICS files | [x] | [x] |
|
||||
| 13 | Add Google Calendar OAuth sync on web | [x] | [x] |
|
||||
| 14 | Add batch destination review/editing for imported web events | [x] | [x] |
|
||||
| 15 | Add mobile native calendar sync with calendar selection | [x] | [x] |
|
||||
| 16 | Add event editing on web and mobile | [x] | [x] |
|
||||
|
||||
| # | Step | ✅ | ✔️ |
|
||||
|---|---|----|----|
|
||||
| 12 | Update TrainSection props (arrival buffer + walk option) | [x] | [x] |
|
||||
| 13 | Update JourneyList with arrival buffer filtering | [x] | [x] |
|
||||
## Transit Integrations
|
||||
|
||||
## Phase 6 — Verification & Testing (Steps 14-16)
|
||||
| # | Step | Done | Verified |
|
||||
| --- | --- | --- | --- |
|
||||
| 17 | Add HAFAS station search and nearest-station lookup | [x] | [x] |
|
||||
| 18 | Add HAFAS journey parsing with real-time delay/cancellation support | [x] | [x] |
|
||||
| 19 | Add optional ÖBB GTFS train metadata enrichment | [x] | [x] |
|
||||
| 20 | Add Wiener Linien nearby stops and monitor departures | [x] | [x] |
|
||||
|
||||
| # | Step | ✅ | ✔️ |
|
||||
|---|---|----|----|
|
||||
| 14 | Integration verification (manual testing) | [ ] | [ ] |
|
||||
| 15 | Build verification (typecheck, lint, test, build) | [x] | [x] |
|
||||
| 16 | Update api-client exports | [x] | [x] |
|
||||
## Verification
|
||||
|
||||
---
|
||||
|
||||
**Legend:**
|
||||
- ✅ = Done (code written)
|
||||
- ✔️ = Verified (tests/builds pass)
|
||||
- `[~]` = Optional or deferred (never blocks phase advancement)
|
||||
| # | Step | Done | Verified |
|
||||
| --- | --- | --- | --- |
|
||||
| 21 | Web unit and route tests | [x] | [x] |
|
||||
| 22 | Mobile store, calendar, notification, and screen tests | [x] | [x] |
|
||||
| 23 | Root lint/typecheck/test scripts documented | [x] | [x] |
|
||||
| 24 | Manual integration checklist updated | [x] | [x] |
|
||||
|
||||
@@ -1,216 +1,145 @@
|
||||
# TimeToLeave - Manual Integration Testing Checklist
|
||||
# TimeToLeave - Manual Testing Checklist
|
||||
|
||||
## Overview
|
||||
This checklist guides you through manual testing of the TimeToLeave application to ensure all features work correctly in the browser.
|
||||
Use this checklist for browser, mobile, and integration testing before release.
|
||||
|
||||
## Prerequisites
|
||||
- [ ] Application is running locally or deployed
|
||||
- [ ] All required environment variables are set
|
||||
- [ ] Network connection is available for external API calls
|
||||
|
||||
---
|
||||
- [ ] `npm install` has been run.
|
||||
- [ ] Required environment variables are configured.
|
||||
- [ ] Web app is running locally or deployed.
|
||||
- [ ] Mobile app has a reachable `EXPO_PUBLIC_API_BASE_URL` when tested on a device.
|
||||
- [ ] Network access is available for HAFAS, Nominatim, OSRM, Wiener Linien, and calendar providers.
|
||||
|
||||
## 1. Settings Infrastructure Testing
|
||||
## Web Dashboard
|
||||
|
||||
### Arrival Buffer Settings
|
||||
- [ ] Navigate to Settings panel
|
||||
- [ ] Set arrival buffer to 10 minutes
|
||||
- [ ] Verify buffer value is displayed correctly
|
||||
- [ ] Test different buffer values (0, 5, 15, 30 minutes)
|
||||
- [ ] Verify buffer value persists after page refresh
|
||||
- [ ] Open `/`.
|
||||
- [ ] Verify the departure desk loads without console errors.
|
||||
- [ ] Add or import at least two future events.
|
||||
- [ ] Verify the dashboard shows the next upcoming event.
|
||||
- [ ] Verify edit and remove actions work from the event card.
|
||||
- [ ] Verify event data persists after browser refresh.
|
||||
|
||||
### Walking Option Toggle
|
||||
- [ ] Enable "Show walking option" toggle
|
||||
- [ ] Verify toggle state is saved
|
||||
- [ ] Disable "Show walking option" toggle
|
||||
- [ ] Verify toggle state persists after page refresh
|
||||
## Web Calendar Import
|
||||
|
||||
### Bike Option Toggle
|
||||
- [ ] Enable "Show bike option" toggle
|
||||
- [ ] Verify toggle state is saved
|
||||
- [ ] Disable "Show bike option" toggle
|
||||
- [ ] Verify toggle state persists after page refresh
|
||||
- [ ] Open `/calendar`.
|
||||
- [ ] Import a valid allowed ICS URL.
|
||||
- [ ] Upload a local `.ics` file.
|
||||
- [ ] Verify imported events with locations merge into the local event store.
|
||||
- [ ] Verify duplicate imports do not create unusable duplicate records.
|
||||
- [ ] Use batch destination editing and confirm edited destinations are retained.
|
||||
|
||||
---
|
||||
## Google Calendar Web Sync
|
||||
|
||||
## 2. Walk Routing Testing
|
||||
- [ ] Confirm `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI`, and `DEPLOYMENT_URL` are configured.
|
||||
- [ ] Open the Google tab on `/calendar`.
|
||||
- [ ] Connect Google Calendar through OAuth.
|
||||
- [ ] Verify sync returns upcoming events with locations.
|
||||
- [ ] Disconnect Google Calendar.
|
||||
- [ ] Verify status returns to disconnected.
|
||||
|
||||
### Walk Route API
|
||||
- [ ] Open Developer Tools (F12) → Network tab
|
||||
- [ ] Trigger a walk route calculation (e.g., by loading an event with walk mode)
|
||||
- [ ] Verify `/api/walk-route` request appears in network log
|
||||
- [ ] Check request contains correct query parameters (fromLat, fromLng, toLat, toLng)
|
||||
- [ ] Verify response contains distance, duration, and steps array
|
||||
- [ ] Test with different coordinate pairs
|
||||
## Settings and Reminders
|
||||
|
||||
### Walk Route Display
|
||||
- [ ] Enable walking option in settings
|
||||
- [ ] Load an event that should show walk route
|
||||
- [ ] Verify walk duration appears under train section
|
||||
- [ ] Verify walk distance is displayed
|
||||
- [ ] Verify step-by-step instructions are shown
|
||||
- [ ] Test with events at different locations
|
||||
- [ ] Enable browser notifications when prompted.
|
||||
- [ ] Change reminder buffer and arrival buffer.
|
||||
- [ ] Toggle walking option off and on.
|
||||
- [ ] Toggle bike option off and on.
|
||||
- [ ] Refresh the page and verify settings persist.
|
||||
- [ ] Verify disabling bike hides or disables bike mode.
|
||||
- [ ] Verify disabling walking removes the final-walk adjustment from train mode.
|
||||
|
||||
---
|
||||
## Train Mode
|
||||
|
||||
## 3. Departure Time Calculation Testing
|
||||
- [ ] Use an event with a real destination and origin station.
|
||||
- [ ] Verify destination geocoding completes.
|
||||
- [ ] Verify destination station lookup completes.
|
||||
- [ ] Verify `/api/hafas` is called with `TripSearch`.
|
||||
- [ ] Verify journey rows show departure, arrival, platform, train labels, delay, changes, and cancellations when present.
|
||||
- [ ] Verify leave-by time is based on a journey that arrives before the event minus arrival buffer and final walk.
|
||||
- [ ] Increase arrival buffer and verify leave-by can move earlier.
|
||||
|
||||
### Countdown Badge
|
||||
- [ ] Set arrival buffer to 10 minutes
|
||||
- [ ] Verify countdown badge shows earlier departure time than event time
|
||||
- [ ] Test with different event times (now, in 1 hour, in 3 hours)
|
||||
- [ ] Verify countdown updates in real-time
|
||||
## Bike and Walking Routes
|
||||
|
||||
### Departure Time Override
|
||||
- [ ] Switch between transport modes (train, bike, walk)
|
||||
- [ ] Verify countdown updates to reflect selected mode
|
||||
- [ ] Test mode switching multiple times
|
||||
- [ ] Verify departure time calculation is consistent
|
||||
- [ ] Switch to bike mode.
|
||||
- [ ] Verify `/api/bike-route` is called with four coordinate parameters.
|
||||
- [ ] Verify bike duration, distance, and steps are displayed.
|
||||
- [ ] Switch back to train mode with walking enabled.
|
||||
- [ ] Verify `/api/walk-route` is called for station-to-destination walking.
|
||||
- [ ] Verify walking duration and distance appear in the train section.
|
||||
- [ ] Test route error handling with invalid or very distant coordinates.
|
||||
|
||||
---
|
||||
## Wiener Linien
|
||||
|
||||
## 4. Mode Selector Testing
|
||||
- [ ] Use a destination near Vienna public transport.
|
||||
- [ ] Verify `/api/wienerlinien/stops` returns nearby stops.
|
||||
- [ ] Verify `/api/wienerlinien/monitor` returns live departures for selected stops.
|
||||
- [ ] Verify loading, empty, and error states are readable.
|
||||
|
||||
### Transport Mode Selection
|
||||
- [ ] Verify "Train" mode is selected by default
|
||||
- [ ] Click "Bike" mode button
|
||||
- [ ] Verify "Bike" mode is now active
|
||||
- [ ] Click "Walk" mode button
|
||||
- [ ] Verify "Walk" mode is now active
|
||||
- [ ] Test switching between all modes multiple times
|
||||
## API Guards
|
||||
|
||||
### Conditional Rendering
|
||||
- [ ] With walking option disabled: verify walk section is hidden
|
||||
- [ ] With walking option enabled: verify walk section appears
|
||||
- [ ] With bike option disabled: verify bike section is hidden
|
||||
- [ ] With bike option enabled: verify bike section appears
|
||||
- [ ] Test all combinations of toggle states
|
||||
- [ ] Verify remote calendar URLs from unsupported hosts are rejected.
|
||||
- [ ] Verify private or localhost calendar URLs are rejected.
|
||||
- [ ] Verify overly large HAFAS POST bodies are rejected.
|
||||
- [ ] Verify invalid coordinates return client errors.
|
||||
- [ ] Verify CORS allows only configured origins.
|
||||
- [ ] Verify rate limiting returns `429` after the configured threshold.
|
||||
|
||||
---
|
||||
## Mobile Event Flow
|
||||
|
||||
## 5. JourneyList Filtering Testing
|
||||
- [ ] Start the Expo app.
|
||||
- [ ] Add a manual event.
|
||||
- [ ] Edit the event.
|
||||
- [ ] Delete the event.
|
||||
- [ ] Restart the app and verify stored events persist.
|
||||
- [ ] Open event detail and verify train, bike, walking, and nearby-stop sections load when data is available.
|
||||
|
||||
### Arrival Buffer Filtering
|
||||
- [ ] Set arrival buffer to 5 minutes
|
||||
- [ ] Load multiple journeys with different arrival times
|
||||
- [ ] Verify journeys arriving too late are filtered out
|
||||
- [ ] Increase arrival buffer to 15 minutes
|
||||
- [ ] Verify previously filtered journeys now appear
|
||||
- [ ] Test filtering with real-world journey data
|
||||
## Mobile Calendar Import
|
||||
|
||||
---
|
||||
- [ ] Import an ICS URL.
|
||||
- [ ] Grant calendar permission.
|
||||
- [ ] Verify native calendars are listed.
|
||||
- [ ] Select and deselect individual calendars.
|
||||
- [ ] Use select all and deselect all.
|
||||
- [ ] Sync native calendars for the next 30 days.
|
||||
- [ ] Verify events without locations are excluded.
|
||||
- [ ] Verify CalDAV/DAVx, Apple, Google, Exchange, subscribed, local, and other source labels render correctly when available on the device.
|
||||
|
||||
## 6. Cross-Feature Integration Testing
|
||||
## Mobile Settings and Notifications
|
||||
|
||||
### Complete Workflow
|
||||
- [ ] Open settings and set arrival buffer to 10 minutes
|
||||
- [ ] Enable walking option
|
||||
- [ ] Enable bike option
|
||||
- [ ] Load an event with multiple journey options
|
||||
- [ ] Verify countdown badge shows earlier departure time
|
||||
- [ ] Switch to bike mode and verify countdown updates
|
||||
- [ ] Verify walk duration appears under train section
|
||||
- [ ] Disable bike option and verify bike section disappears
|
||||
- [ ] Re-enable bike option and verify bike section reappears
|
||||
- [ ] Test complete workflow with different events
|
||||
- [ ] Search for an origin station.
|
||||
- [ ] Use current location to find nearest origin station.
|
||||
- [ ] Change reminder buffer and arrival buffer.
|
||||
- [ ] Toggle walking and bike options.
|
||||
- [ ] Toggle notifications.
|
||||
- [ ] Verify notification settings persist after app restart.
|
||||
- [ ] Verify scheduled notifications are recreated when settings change.
|
||||
- [ ] Toggle dark/light theme and verify it persists.
|
||||
|
||||
---
|
||||
## Offline and Failure States
|
||||
|
||||
## 7. Edge Cases Testing
|
||||
- [ ] Disable network and open web event detail data.
|
||||
- [ ] Verify geocoding, HAFAS, route, and Wiener Linien errors are visible and non-blocking.
|
||||
- [ ] Re-enable network and verify retry/refresh paths work.
|
||||
- [ ] Test mobile with the backend URL unavailable and verify errors are understandable.
|
||||
|
||||
### Empty States
|
||||
- [ ] Test with no walk route available (remote location)
|
||||
- [ ] Verify appropriate error message is displayed
|
||||
- [ ] Test with missing coordinates
|
||||
- [ ] Verify graceful handling of missing data
|
||||
## Accessibility and Layout
|
||||
|
||||
### Network Errors
|
||||
- [ ] Disable network connection (offline mode in DevTools)
|
||||
- [ ] Attempt to load walk route
|
||||
- [ ] Verify error state is displayed
|
||||
- [ ] Re-enable network and verify retry works
|
||||
|
||||
### Invalid Data
|
||||
- [ ] Test with invalid coordinate values
|
||||
- [ ] Test with zero or negative buffer times
|
||||
- [ ] Verify application handles invalid data gracefully
|
||||
|
||||
---
|
||||
|
||||
## 8. Accessibility Testing
|
||||
|
||||
### Keyboard Navigation
|
||||
- [ ] Tab through all settings controls
|
||||
- [ ] Verify all buttons and toggles are keyboard accessible
|
||||
- [ ] Test mode selector with keyboard only
|
||||
|
||||
### Screen Reader Compatibility
|
||||
- [ ] Use Chrome's accessibility inspector or a screen reader
|
||||
- [ ] Verify all settings have proper labels
|
||||
- [ ] Verify all interactive elements are announced correctly
|
||||
|
||||
### High Contrast Mode
|
||||
- [ ] Enable high contrast mode in OS settings
|
||||
- [ ] Verify all UI elements remain visible and readable
|
||||
|
||||
---
|
||||
|
||||
## 9. Performance Testing
|
||||
|
||||
### Loading Times
|
||||
- [ ] Measure time to load walk route for nearby location (< 5km)
|
||||
- [ ] Measure time to load walk route for farther location (10-20km)
|
||||
- [ ] Verify loading spinner appears during API calls
|
||||
- [ ] Verify loading spinner disappears when complete
|
||||
|
||||
### Memory Usage
|
||||
- [ ] Open Developer Tools → Memory tab
|
||||
- [ ] Perform multiple walk route calculations
|
||||
- [ ] Verify no memory leaks (memory usage should stabilize)
|
||||
|
||||
---
|
||||
|
||||
## 10. Responsive Design Testing
|
||||
|
||||
### Mobile
|
||||
- [ ] Test on mobile device (iPhone/Android)
|
||||
- [ ] Verify settings panel is usable on small screens
|
||||
|
||||
### Tablet
|
||||
- [ ] Test on tablet device
|
||||
- [ ] Verify all controls are properly sized
|
||||
|
||||
### Desktop
|
||||
- [ ] Test on various desktop screen sizes
|
||||
- [ ] Verify layout does not break
|
||||
|
||||
---
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
When you encounter an issue during testing:
|
||||
|
||||
1. Note the exact steps to reproduce
|
||||
2. Record browser/device information
|
||||
3. Capture any error messages or console logs
|
||||
4. Take screenshots if UI is affected
|
||||
5. Test with latest code after reporting
|
||||
|
||||
---
|
||||
- [ ] Navigate web controls with keyboard only.
|
||||
- [ ] Verify modal focus and close behavior.
|
||||
- [ ] Verify buttons and interactive controls have accessible labels or readable text.
|
||||
- [ ] Test narrow mobile browser width, tablet width, and desktop width.
|
||||
- [ ] Verify mobile screens do not clip primary controls.
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [ ] All required tests passed successfully
|
||||
- [ ] No critical bugs found
|
||||
- [ ] Application ready for production deployment
|
||||
- [ ] Web smoke test passed.
|
||||
- [ ] Mobile smoke test passed.
|
||||
- [ ] Calendar import tested.
|
||||
- [ ] Live transit integration tested.
|
||||
- [ ] Notifications tested.
|
||||
- [ ] No critical bugs remain.
|
||||
|
||||
**Tested by:** ________________________
|
||||
**Date:** ________________________
|
||||
**Browser/Device:** ________________________
|
||||
**Build Version:** ________________________
|
||||
Tested by:
|
||||
|
||||
---
|
||||
Date:
|
||||
|
||||
## Additional Notes
|
||||
|
||||
_Add any observations, workarounds, or special test conditions here._
|
||||
Build/version:
|
||||
|
||||
@@ -1,79 +1,87 @@
|
||||
# TimeToLeave - Post-MVP Improvements Plan
|
||||
|
||||
> **Last updated:** 2026-05-19
|
||||
> This plan reflects the current state of the codebase after the initial MVP and mobile rewrite.
|
||||
|
||||
## Already Implemented (No Longer TODO)
|
||||
|
||||
The following items from earlier versions of this plan have been completed and are in production:
|
||||
|
||||
- **Native Calendar Import** — `expo-calendar` integration with device calendar sync, permission requests, and multiple calendar source selection.
|
||||
- **Push / Local Notifications** — `expo-notifications` with local notification scheduling. Server-side push (FCM/APNs) for live journey updates remains deferred.
|
||||
- **Dark / Light Theming** — System theme following and manual dark/light toggle on both web and mobile.
|
||||
- **Auto-Refresh** — `useFocusEffect`-based refresh on mobile when returning to foreground.
|
||||
|
||||
---
|
||||
|
||||
## High Priority
|
||||
|
||||
### 1. Native Calendar Import
|
||||
- Integrate with expo-calendar or react-native-calendar-events
|
||||
- Request calendar permissions
|
||||
- Auto-sync events from Google/Apple calendars
|
||||
- Support multiple calendar sources
|
||||
### 1. Offline-First Architecture
|
||||
- Cache journey and route data in `AsyncStorage` / `localStorage` for offline viewing.
|
||||
- Background sync when connection is restored.
|
||||
- Add explicit "offline mode" UI indicators.
|
||||
- Conflict resolution for concurrent event edits.
|
||||
|
||||
### 2. Push Notifications
|
||||
- Implement FCM for Android and APNs for iOS
|
||||
- Server-side notification triggers for journey changes
|
||||
- Real-time updates when train status changes
|
||||
- Fallback to local notifications when offline
|
||||
### 2. Real Map Integration
|
||||
- Integrate `expo-maps` / `@vis.gl/react-google-maps` for visual route display.
|
||||
- Show origin, destination, and train stations on a map.
|
||||
- Display bike route with turn-by-turn directions.
|
||||
- Alternative route suggestions (e.g., faster vs. fewer changes).
|
||||
|
||||
### 3. Offline-First Architecture
|
||||
- Use expo-sqlite for local caching
|
||||
- Cache journey data for offline access
|
||||
- Background sync when connection restored
|
||||
- Conflict resolution for concurrent edits
|
||||
### 3. Multiple Origins Support
|
||||
- Allow different origins per event (Home, Work, Custom presets).
|
||||
- Quick origin switching in event detail and settings.
|
||||
- Store origin presets in persistent settings.
|
||||
|
||||
---
|
||||
|
||||
## Medium Priority
|
||||
|
||||
### 4. Real Map Integration
|
||||
- Integrate expo-maps for visual route display
|
||||
- Show train stations on map
|
||||
- Display bike route with turn-by-turn directions
|
||||
- Alternative route suggestions
|
||||
### 4. Server-Side Push Notifications
|
||||
- Implement FCM for Android and APNs for iOS for real-time journey disruption alerts.
|
||||
- Real-time delay/cancellation push notifications.
|
||||
- Fallback to local notifications when the server is unreachable.
|
||||
|
||||
### 5. Multiple Origins Support
|
||||
- Allow different origins per event
|
||||
- Home/Work/Custom origin presets
|
||||
- Quick origin switching in event detail
|
||||
### 5. Accessibility
|
||||
- TalkBack / VoiceOver screen reader support on mobile.
|
||||
- Dynamic type scaling.
|
||||
- High contrast mode.
|
||||
- WCAG 2.1 AA compliance audit on web.
|
||||
|
||||
### 6. Auto-Refresh
|
||||
- Refresh journey data when returning to app
|
||||
- Background refresh for active events
|
||||
- Configurable refresh intervals
|
||||
### 6. Analytics & Crash Reporting
|
||||
- Integrate Sentry for error tracking on web and mobile.
|
||||
- Opt-in usage analytics.
|
||||
- Performance monitoring (Web Vitals, React Native startup time).
|
||||
- In-app user feedback collection.
|
||||
|
||||
---
|
||||
|
||||
## Lower Priority
|
||||
|
||||
### 7. Accessibility
|
||||
- TalkBack/VoiceOver support
|
||||
- Dynamic type scaling
|
||||
- High contrast mode
|
||||
- Screen reader optimizations
|
||||
### 7. Advanced Features
|
||||
- Shared events with friends / family (collaborative departure planning).
|
||||
- Recurring event templates.
|
||||
- Journey history and statistics dashboard.
|
||||
- Export / import event data (ICS, JSON).
|
||||
|
||||
### 8. Theming
|
||||
- Dark mode support
|
||||
- System theme following
|
||||
- Custom color schemes
|
||||
- Accessibility-compliant color contrasts
|
||||
---
|
||||
|
||||
### 9. Analytics & Crash Reporting
|
||||
- Sentry or similar for error tracking
|
||||
- Usage analytics (opt-in)
|
||||
- Performance monitoring
|
||||
- User feedback collection
|
||||
## Technical Debt & Quality
|
||||
|
||||
### 10. Advanced Features
|
||||
- Shared events with friends/family
|
||||
- Recurring event templates
|
||||
- Journey history and statistics
|
||||
- Export/import event data
|
||||
### 8. Code Quality
|
||||
- Extract shared hooks to `packages/hooks` (deduplicate web and mobile hook implementations).
|
||||
- Increase unit test coverage across all packages.
|
||||
- Add Playwright E2E tests for web critical flows.
|
||||
- Add Detox E2E tests for mobile critical flows.
|
||||
- Bundle size analysis and reduction.
|
||||
|
||||
## Technical Debt
|
||||
### 9. Developer Experience
|
||||
- Consolidate ESLint to Flat Config everywhere.
|
||||
- Add pre-commit hooks (`husky` + `lint-staged`).
|
||||
- Add GitHub Actions CI pipeline.
|
||||
- Architecture Decision Records (ADRs) in `docs/adr/`.
|
||||
|
||||
### 11. Code Quality
|
||||
- More comprehensive test coverage
|
||||
- E2E tests for critical flows
|
||||
- Performance optimization
|
||||
- Bundle size reduction
|
||||
|
||||
### 12. Documentation
|
||||
- User documentation
|
||||
- API documentation
|
||||
- Contributing guidelines
|
||||
- Architecture decisions (ADRs)
|
||||
### 10. Documentation
|
||||
- Keep `POST_MVP_PLAN.md` and `CHECKLIST.md` in sync with reality.
|
||||
- Contributing guidelines (`CONTRIBUTING.md`).
|
||||
- API versioning policy once the backend grows.
|
||||
|
||||
@@ -1,27 +1,45 @@
|
||||
# Privacy Policy
|
||||
|
||||
## Information We Collect
|
||||
TimeToLeave is designed to keep user data local where possible. The app does not include third-party analytics or advertising trackers.
|
||||
|
||||
We do not collect any personal information or data from users. All data is stored locally on your device.
|
||||
## Data Stored Locally
|
||||
|
||||
## Data Usage
|
||||
- Web events and reminder settings are stored in browser `localStorage`.
|
||||
- Mobile events, origin station, notification settings, theme, and selected native calendars are stored in `AsyncStorage`.
|
||||
- Mobile notifications are scheduled locally through Expo notifications.
|
||||
|
||||
- **Location Data**: We use your device's location to find nearby stations and calculate travel times. This data is only used for the app's functionality and is not stored or transmitted.
|
||||
- **Calendar Data**: If you choose to import calendar events, we only read the events from your calendar and do not store or transmit them.
|
||||
- **Notifications**: We use local notifications to remind you about events, which are stored locally on your device.
|
||||
## Data Sent to External Services
|
||||
|
||||
## Data Storage
|
||||
Some features require network calls to calculate routes or import calendars:
|
||||
|
||||
All data is stored locally on your device and never leaves your device. We do not use any third-party analytics or tracking services.
|
||||
| Data | Sent to | Purpose |
|
||||
| --- | --- | --- |
|
||||
| Destination text or address | Nominatim | Convert a place into coordinates. |
|
||||
| Coordinates | OSRM | Calculate bike and walking routes. |
|
||||
| Station IDs, dates, and times | ÖBB HAFAS | Search stations and live public-transport journeys. |
|
||||
| Coordinates or stop IDs | Wiener Linien | Find nearby stops and live departures. |
|
||||
| Calendar URL | TimeToLeave backend, then the calendar host | Fetch and parse remote ICS feeds. |
|
||||
| Google Calendar authorization code and tokens | Google and the TimeToLeave backend | Connect and sync Google Calendar on web. |
|
||||
| Device calendar event fields | Local mobile app process | Import native calendar events with locations. |
|
||||
|
||||
## Third-Party Services
|
||||
Remote ICS imports are restricted by server-side URL validation. Private and reserved hosts are blocked.
|
||||
|
||||
We do not use any third-party services that might collect or process your data. All processing happens locally on your device.
|
||||
## Google Calendar
|
||||
|
||||
## Changes to This Privacy Policy
|
||||
Google Calendar sync is optional. When connected on the web app, OAuth tokens are stored in HTTP-only cookies and used only to fetch calendar events. Disconnecting Google Calendar deletes the token cookie.
|
||||
|
||||
We may update our Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page.
|
||||
## Location
|
||||
|
||||
## Contact Us
|
||||
Location access is optional and used to find nearby stations or calculate routes. Coordinates may be sent to route, geocoding, or transit APIs only when the corresponding feature is used.
|
||||
|
||||
If you have any questions about this Privacy Policy, please contact us at [contact email].
|
||||
## Calendar Data
|
||||
|
||||
Only events with locations are useful to TimeToLeave. Imported events are normalized to title, destination, event time, source, and ID. The app stores those normalized events locally.
|
||||
|
||||
## Data Retention
|
||||
|
||||
Local data remains until the user clears app/browser storage, deletes events, disconnects Google Calendar, or uninstalls the app. Server-side proxy routes are intended for request handling and do not provide application-level persistent event storage.
|
||||
|
||||
## Changes
|
||||
|
||||
This policy may be updated as the app changes. Updates are made in this repository.
|
||||
|
||||
@@ -1,147 +1,128 @@
|
||||
# ⏱️ TimeToLeave
|
||||
# TimeToLeave
|
||||
|
||||
TimeToLeave is a departure planner for calendar-driven travel. It imports events with locations, resolves the closest public-transport station, checks live ÖBB HAFAS and Wiener Linien data, and shows when to leave by train or bike.
|
||||
|
||||

|
||||
|
||||
> **TimeToLeave** is a smart departure planner that tells you exactly when to leave home to catch your public transport for upcoming appointments. It syncs with your personal calendar, checks real-time train/bus departures (HAFAS & WienerLinien), and provides a live "Leave Status" based on real-time delays.
|
||||
    
|
||||
|
||||
   
|
||||
## What It Does
|
||||
|
||||
## 🚀 How It Works
|
||||
1. Imports events from ICS URLs, ICS files, Google Calendar on web, or native device calendars on mobile.
|
||||
2. Stores events locally in browser `localStorage` or mobile `AsyncStorage`.
|
||||
3. Uses browser or device geolocation, a saved station, or the default Mödling origin.
|
||||
4. Geocodes event destinations and resolves nearby stations through HAFAS `LocMatch`.
|
||||
5. Searches ÖBB HAFAS journeys, enriches train data with the ÖBB GTFS fallback when available, and shows real-time delays and cancellations.
|
||||
6. Calculates bike and final walking routes through OSRM.
|
||||
7. Shows a live leave-by countdown and optional browser/mobile notifications.
|
||||
|
||||
1. **Sync Your Calendar:** Import your `.ics` file or provide a calendar URL. The app extracts your upcoming events and destinations.
|
||||
2. **Set Your Origin:** Define your home station or let the app use your current geolocation.
|
||||
3. **Journey Calculation:** The app queries the HAFAS protocol and WienerLinien APIs to find the best public transport connections to your event destination.
|
||||
4. **Real-Time Monitoring:** It monitors your train's real-time departure time, accounts for delays, and adds your local travel time (e.g., biking to the station) to calculate a dynamic countdown.
|
||||
5. **Leave Status:** You get a clear status: `Leave now`, `On time`, `Delayed +X min`, or `Departure missed`.
|
||||
## Repository Layout
|
||||
|
||||
## 🧱 Project Structure
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `apps/web/` | Next.js 16 App Router web UI plus backend proxy routes for HAFAS, geocoding, routing, calendar parsing, Google Calendar, and Wiener Linien. |
|
||||
| `apps/mobile/` | Expo 54 / React Native 0.81 mobile app with native calendar, location, notification, and local storage integrations. |
|
||||
| `packages/core/` | Shared types, defaults, HAFAS time parsing, journey parsing/scoring, countdown, formatting, and status utilities. |
|
||||
| `packages/api-client/` | Shared client for calling the web app's `/api/*` backend routes from web hooks and the mobile app. |
|
||||
| `docs/` | Architecture, development, API, user, and codebase reference documentation. |
|
||||
|
||||
This project uses a monorepo setup (npm workspaces) to manage multiple, interconnected parts:
|
||||
## Prerequisites
|
||||
|
||||
| Directory | Description |
|
||||
| :--- | :--- |
|
||||
| `apps/web/` | The main web dashboard built with **Next.js 16**, React 19, and Tailwind CSS 4. |
|
||||
| `apps/mobile/` | The on-the-go mobile client built with **React Native 0.81** and **Expo 54**. |
|
||||
| `packages/core/` | Shared domain logic, types (`Event`, `Journey`, `Station`), countdown utilities, and status calculators. |
|
||||
| `packages/api-client/` | A lightweight client that handles API proxies for HAFAS requests, calendar parsing, geocoding, and bike routing. |
|
||||
- Node.js 20 or newer
|
||||
- npm 9 or newer
|
||||
- For mobile native builds: Expo/EAS prerequisites plus Android Studio or Xcode as needed
|
||||
|
||||
## 🛠 Development & Running the Application
|
||||
## Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
* Node.js (version 20.x or higher)
|
||||
* npm (version 9.x or higher)
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Clone the repository:**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd TimeToLeave
|
||||
```
|
||||
|
||||
2. **Install dependencies:**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Environment variables:**
|
||||
* For `apps/web` and `apps/mobile`, copy `.env.example` to `.env` in each app directory and update the backend API URL and any required keys.
|
||||
|
||||
### Available Scripts
|
||||
|
||||
| Script | Command | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `dev` | `npm run dev` | Starts the Next.js development server for the Web dashboard. |
|
||||
| `dev:mobile` | `npm run dev:mobile` | Starts the Expo development server for the Mobile client. |
|
||||
| `build` | `npm run build` | Builds the production bundle for the Web application. |
|
||||
| `test` | `npm run test` | Runs Vitest for the Web app and Jest for the Mobile app. |
|
||||
| `lint` | `npm run lint` | Runs ESLint across both web and mobile clients. |
|
||||
| `typecheck` | `npm run typecheck` | Runs TypeScript type checking across all workspaces. |
|
||||
|
||||
## 📝 Key Features & Tech Stack
|
||||
|
||||
### Web Application (`apps/web`)
|
||||
* **Framework:** Next.js 16.2.6 (App Router)
|
||||
* **UI:** React 19.2.4 with Tailwind CSS 4
|
||||
* **State Management:** React Context (via `EventsProvider` and `ReminderSettingsProvider`)
|
||||
* **Routing:** Next.js built-in routing for `/` (event list) and `/calendar` views.
|
||||
|
||||
### Mobile Application (`apps/mobile`)
|
||||
* **Framework:** React Native 0.81 via Expo 54
|
||||
* **Navigation:** React Navigation 7 (Native Stack)
|
||||
* **Device APIs:**
|
||||
* `expo-location`: For geocoding your current position.
|
||||
* `expo-calendar`: For native calendar event integration.
|
||||
* `expo-notifications`: For native push notifications when it's time to leave.
|
||||
* `@react-native-async-storage/async-storage`: For persisting settings and local state.
|
||||
|
||||
### Core Logic (`packages/core`)
|
||||
* **Countdown Utilities:** Calculates time-deltas and assigns color codes (Red/Orange/Yellow/Green/Blue) based on urgency.
|
||||
* **HAFAS Time Parsing:** Highly accurate timezone-aware parsing for HAFAS timestamps, specifically handling `Europe/Vienna` (CET/CEST) and DST transitions.
|
||||
* **WienerLinien Support:** Native types and handling for Vienna public transport departures.
|
||||
* **Leave Status:** Derives human-readable statuses (`Leave now`, `Delayed +10 min`, etc.) by comparing the best non-cancelled journey's real departure time against the current time.
|
||||
|
||||
## 📄 API Client Usage
|
||||
|
||||
The `@timetoleave/api-client` package provides a clean interface to interact with your backend proxy, which handles the heavy lifting of HAFAS protocol communication and calendar parsing.
|
||||
|
||||
```typescript
|
||||
import { ApiClient } from "@timetoleave/api-client";
|
||||
|
||||
// Initialize with your backend URL
|
||||
const api = new ApiClient("http://localhost:3000");
|
||||
|
||||
// 1. Sync your calendar
|
||||
const events = await api.fetchCalendar("https://example.com/calendar.ics", 7);
|
||||
|
||||
// 2. Search for a station via the HAFAS LocMatch endpoint
|
||||
const stationResult = await api.hafasRequest({
|
||||
svcReqL: [
|
||||
{
|
||||
meth: "LocMatch",
|
||||
req: { searchTxt: "Wien Mitte", maxMatches: 5 },
|
||||
},
|
||||
],
|
||||
});
|
||||
const stations = stationResult?.svcReqL?.[0]?.res?.locL ?? [];
|
||||
|
||||
// 3. Find journeys between stations for a specific date
|
||||
const journeys = await api.searchJourneys(
|
||||
stations[0].extId, // From
|
||||
"dest:extId", // To
|
||||
new Date() // Date
|
||||
);
|
||||
|
||||
// 4. Get a bike route from your current location to the station
|
||||
const bikeRoute = await api.getBikeRoute(
|
||||
48.2082, 16.3738, // From lat/lng
|
||||
48.1850, 16.3780 // To lat/lng
|
||||
);
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
## 🛡️ Testing & Quality Assurance
|
||||
The web app reads environment variables from the workspace process. For deployment, configure the same values in the hosting environment.
|
||||
|
||||
The project provides comprehensive scripts for maintaining code quality:
|
||||
Important variables:
|
||||
|
||||
* **Linting:** Use `npm run lint` to catch stylistic and structural errors via ESLint 9.
|
||||
* **Type Checking:** Use `npm run typecheck` to ensure strict type safety across the codebase via TypeScript 5.
|
||||
* **Testing:**
|
||||
* The web application uses **Vitest** (v4.1.5) with **jsdom** and **@testing-library/react**.
|
||||
* The mobile application uses **Jest** (v29.7.0) with **jest-expo** and **react-test-renderer**.
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `HAFAS_URL` | ÖBB HAFAS endpoint. Defaults to `https://fahrplan.oebb.at/bin/mgate.exe`. |
|
||||
| `NOMINATIM_URL` and `NOMINATIM_USER_AGENT` | Geocoding endpoint and required user agent. |
|
||||
| `OSRM_URL` | Routing endpoint used for bike and foot profiles. |
|
||||
| `WIENER_LINIEN_API_URL` | Wiener Linien live data base URL. |
|
||||
| `OEBB_GTFS_URL` | Optional ÖBB GTFS ZIP used to enrich HAFAS train metadata. |
|
||||
| `CORS_ALLOWED_ORIGINS` | Comma-separated origins allowed to call `/api/*`. |
|
||||
| `DEPLOYMENT_URL` | Public base URL used by Google OAuth redirects. |
|
||||
| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI` | Required for Google Calendar sync on web. |
|
||||
| `EXPO_PUBLIC_API_BASE_URL` | Mobile backend URL. Set this for device builds so the app can reach the deployed web backend. |
|
||||
|
||||
## 📂 File Structure
|
||||
## Development
|
||||
|
||||
```text
|
||||
├── apps/
|
||||
│ ├── mobile/ # Mobile application using React Native and Expo
|
||||
│ └── web/ # Web application using Next.js and Tailwind CSS
|
||||
├── packages/
|
||||
│ ├── api-client/ # API client for HAFAS, Calendar, and Routing proxies
|
||||
│ └── core/ # Shared domain types, countdowns, and HAFAS time utilities
|
||||
├── node_modules/ # Third-party dependencies
|
||||
└── README.md # The file you're reading now
|
||||
```
|
||||
| Command | Description |
|
||||
| --- | --- |
|
||||
| `npm run dev` | Start the Next.js web app on `http://localhost:3000`. |
|
||||
| `npm run dev:mobile` | Start the Expo development server. |
|
||||
| `npm run build` | Build the web app. |
|
||||
| `npm run start` | Start the built web app. |
|
||||
| `npm run test` | Run web Vitest and mobile Jest suites. |
|
||||
| `npm run lint` | Run ESLint across web, mobile, core, and api-client workspaces. |
|
||||
| `npm run typecheck` | Run TypeScript checks across all workspaces. |
|
||||
|
||||
---
|
||||
*Built for developers who bike to the train and hate missing their connections.*
|
||||
## Web App
|
||||
|
||||
Current user-facing routes:
|
||||
|
||||
| Route | Description |
|
||||
| --- | --- |
|
||||
| `/` | Departure desk. Shows the next upcoming event, leave-by status, transport mode selector, train journeys, bike route, final walk, and nearby Wiener Linien departures. |
|
||||
| `/calendar` | Calendar import and management view with URL, file, and Google Calendar tabs plus batch destination editing. |
|
||||
|
||||
The add/edit event UI is a modal component, not a standalone page route.
|
||||
|
||||
## Backend Proxy Routes
|
||||
|
||||
All backend routes live under `apps/web/src/app/api/` and are protected by strict CORS plus per-IP rate limiting in `apps/web/src/proxy.ts`.
|
||||
|
||||
| Endpoint | Methods | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `/api/health` | `GET` | Returns `{ ok, ts, version }`. |
|
||||
| `/api/hafas` | `GET`, `POST` | Convenience journey search or validated HAFAS relay for `TripSearch` and `LocMatch`. |
|
||||
| `/api/geocode` | `GET` | Forward geocoding through Nominatim. |
|
||||
| `/api/bike-route` | `GET` | OSRM bicycle route between two coordinates. |
|
||||
| `/api/walk-route` | `GET` | OSRM foot route between two coordinates. |
|
||||
| `/api/calendar` | `GET` | Fetch and parse an allowed remote ICS URL. |
|
||||
| `/api/calendar/parse` | `POST` | Parse uploaded/raw ICS text. |
|
||||
| `/api/calendar/google` | `GET` | Fetch Google Calendar events using OAuth cookies. |
|
||||
| `/api/auth/google` | `GET` | Start Google OAuth. |
|
||||
| `/api/auth/google/callback` | `GET` | Complete Google OAuth and store token cookie. |
|
||||
| `/api/auth/google/status` | `GET` | Report Google configuration and connection state. |
|
||||
| `/api/auth/google/disconnect` | `POST` | Delete the Google token cookie. |
|
||||
| `/api/wienerlinien/stops` | `GET` | Find nearby Wiener Linien stops. |
|
||||
| `/api/wienerlinien/monitor` | `GET` | Fetch and flatten live stop departures. |
|
||||
|
||||
## Mobile App
|
||||
|
||||
The mobile app includes event list, add/edit event, event detail, calendar import, and settings screens. It supports:
|
||||
|
||||
- Native calendar sync for the next 30 days.
|
||||
- Calendar-source selection, including CalDAV/DAVx, Apple, Google, Exchange, subscribed, and local calendars when exposed by the device.
|
||||
- Saved origin station with current-location lookup.
|
||||
- Train, bike, walking, and Wiener Linien live sections on event detail.
|
||||
- Local notifications scheduled from stored event/settings data.
|
||||
- Dark/light theme toggle.
|
||||
|
||||
## Documentation
|
||||
|
||||
Start with [docs/README.md](docs/README.md), then use:
|
||||
|
||||
- [Architecture](docs/ARCHITECTURE.md)
|
||||
- [Development Guide](docs/DEVELOPMENT.md)
|
||||
- [Core & API Client Reference](docs/API_REFERENCE.md)
|
||||
- [User Guide](docs/USER_GUIDE.md)
|
||||
- [Codebase Function Guide](docs/CODEBASE_FUNCTION_GUIDE.md)
|
||||
|
||||
## Important Implementation Notes
|
||||
|
||||
- HAFAS date/time values are Vienna-local strings. Use `parseHafasTime()` and `hafasDateTime()` from `@timetoleave/core`; avoid ad hoc `Date` parsing for HAFAS payloads.
|
||||
- Remote calendar URLs are restricted to known calendar providers and private/reserved hosts are blocked.
|
||||
- Mobile devices must use a reachable `EXPO_PUBLIC_API_BASE_URL`; same-origin empty base URLs only work in the web app.
|
||||
- This repo uses Next.js 16. Before changing Next.js routing, middleware/proxy, or framework conventions, read the relevant guide in `node_modules/next/dist/docs/`.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# OSX
|
||||
#
|
||||
.DS_Store
|
||||
|
||||
# Android/IntelliJ
|
||||
#
|
||||
build/
|
||||
.idea
|
||||
.gradle
|
||||
local.properties
|
||||
*.iml
|
||||
*.hprof
|
||||
.cxx/
|
||||
|
||||
# Bundle artifacts
|
||||
*.jsbundle
|
||||
@@ -0,0 +1,182 @@
|
||||
apply plugin: "com.android.application"
|
||||
apply plugin: "org.jetbrains.kotlin.android"
|
||||
apply plugin: "com.facebook.react"
|
||||
|
||||
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
|
||||
|
||||
/**
|
||||
* This is the configuration block to customize your React Native Android app.
|
||||
* By default you don't need to apply any configuration, just uncomment the lines you need.
|
||||
*/
|
||||
react {
|
||||
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
|
||||
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
|
||||
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
|
||||
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
|
||||
|
||||
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
|
||||
// Use Expo CLI to bundle the app, this ensures the Metro config
|
||||
// works correctly with Expo projects.
|
||||
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
|
||||
bundleCommand = "export:embed"
|
||||
|
||||
/* Folders */
|
||||
// The root of your project, i.e. where "package.json" lives. Default is '../..'
|
||||
// root = file("../../")
|
||||
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
|
||||
// reactNativeDir = file("../../node_modules/react-native")
|
||||
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
|
||||
// codegenDir = file("../../node_modules/@react-native/codegen")
|
||||
|
||||
/* Variants */
|
||||
// The list of variants to that are debuggable. For those we're going to
|
||||
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
|
||||
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
|
||||
// debuggableVariants = ["liteDebug", "prodDebug"]
|
||||
|
||||
/* Bundling */
|
||||
// A list containing the node command and its flags. Default is just 'node'.
|
||||
// nodeExecutableAndArgs = ["node"]
|
||||
|
||||
//
|
||||
// The path to the CLI configuration file. Default is empty.
|
||||
// bundleConfig = file(../rn-cli.config.js)
|
||||
//
|
||||
// The name of the generated asset file containing your JS bundle
|
||||
// bundleAssetName = "MyApplication.android.bundle"
|
||||
//
|
||||
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
|
||||
// entryFile = file("../js/MyApplication.android.js")
|
||||
//
|
||||
// A list of extra flags to pass to the 'bundle' commands.
|
||||
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
|
||||
// extraPackagerArgs = []
|
||||
|
||||
/* Hermes Commands */
|
||||
// The hermes compiler command to run. By default it is 'hermesc'
|
||||
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
|
||||
//
|
||||
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
|
||||
// hermesFlags = ["-O", "-output-source-map"]
|
||||
|
||||
/* Autolinking */
|
||||
autolinkLibrariesWithApp()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization).
|
||||
*/
|
||||
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
|
||||
|
||||
/**
|
||||
* The preferred build flavor of JavaScriptCore (JSC)
|
||||
*
|
||||
* For example, to use the international variant, you can use:
|
||||
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
|
||||
*
|
||||
* The international variant includes ICU i18n library and necessary data
|
||||
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
|
||||
* give correct results when using with locales other than en-US. Note that
|
||||
* this variant is about 6MiB larger per architecture than default.
|
||||
*/
|
||||
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
|
||||
|
||||
android {
|
||||
ndkVersion rootProject.ext.ndkVersion
|
||||
|
||||
buildToolsVersion rootProject.ext.buildToolsVersion
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
|
||||
namespace 'com.floegger.timetoleave'
|
||||
defaultConfig {
|
||||
applicationId 'com.floegger.timetoleave'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0.0"
|
||||
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
}
|
||||
signingConfigs {
|
||||
debug {
|
||||
storeFile file('debug.keystore')
|
||||
storePassword 'android'
|
||||
keyAlias 'androiddebugkey'
|
||||
keyPassword 'android'
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
release {
|
||||
// Caution! In production, you need to generate your own keystore file.
|
||||
// see https://reactnative.dev/docs/signed-apk-android.
|
||||
signingConfig signingConfigs.debug
|
||||
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
|
||||
shrinkResources enableShrinkResources.toBoolean()
|
||||
minifyEnabled enableMinifyInReleaseBuilds
|
||||
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
|
||||
crunchPngs enablePngCrunchInRelease.toBoolean()
|
||||
}
|
||||
}
|
||||
packagingOptions {
|
||||
jniLibs {
|
||||
def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
|
||||
useLegacyPackaging enableLegacyPackaging.toBoolean()
|
||||
}
|
||||
}
|
||||
androidResources {
|
||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
|
||||
// Apply static values from `gradle.properties` to the `android.packagingOptions`
|
||||
// Accepts values in comma delimited lists, example:
|
||||
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
|
||||
["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop ->
|
||||
// Split option: 'foo,bar' -> ['foo', 'bar']
|
||||
def options = (findProperty("android.packagingOptions.$prop") ?: "").split(",");
|
||||
// Trim all elements in place.
|
||||
for (i in 0..<options.size()) options[i] = options[i].trim();
|
||||
// `[] - ""` is essentially `[""].filter(Boolean)` removing all empty strings.
|
||||
options -= ""
|
||||
|
||||
if (options.length > 0) {
|
||||
println "android.packagingOptions.$prop += $options ($options.length)"
|
||||
// Ex: android.packagingOptions.pickFirsts += '**/SCCS/**'
|
||||
options.each {
|
||||
android.packagingOptions[prop] += it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// The version of react-native is set by the React Native Gradle Plugin
|
||||
implementation("com.facebook.react:react-android")
|
||||
|
||||
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
|
||||
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
|
||||
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
|
||||
|
||||
if (isGifEnabled) {
|
||||
// For animated gif support
|
||||
implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}")
|
||||
}
|
||||
|
||||
if (isWebpEnabled) {
|
||||
// For webp support
|
||||
implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}")
|
||||
if (isWebpAnimatedEnabled) {
|
||||
// Animated webp support
|
||||
implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}")
|
||||
}
|
||||
}
|
||||
|
||||
if (hermesEnabled.toBoolean()) {
|
||||
implementation("com.facebook.react:hermes-android")
|
||||
} else {
|
||||
implementation jscFlavor
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# By default, the flags in this file are appended to flags specified
|
||||
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
|
||||
# You can edit the include path and order by changing the proguardFiles
|
||||
# directive in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# react-native-reanimated
|
||||
-keep class com.swmansion.reanimated.** { *; }
|
||||
-keep class com.facebook.react.turbomodule.** { *; }
|
||||
|
||||
# Add any project specific keep options here:
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
|
||||
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
|
||||
</manifest>
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
|
||||
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
|
||||
</manifest>
|
||||
@@ -0,0 +1,31 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
</queries>
|
||||
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false">
|
||||
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
||||
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="exp+time-to-leave"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.floegger.timetoleave
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
|
||||
import com.facebook.react.ReactActivity
|
||||
import com.facebook.react.ReactActivityDelegate
|
||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
||||
import com.facebook.react.defaults.DefaultReactActivityDelegate
|
||||
|
||||
import expo.modules.ReactActivityDelegateWrapper
|
||||
|
||||
class MainActivity : ReactActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Set the theme to AppTheme BEFORE onCreate to support
|
||||
// coloring the background, status bar, and navigation bar.
|
||||
// This is required for expo-splash-screen.
|
||||
setTheme(R.style.AppTheme);
|
||||
super.onCreate(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name of the main component registered from JavaScript. This is used to schedule
|
||||
* rendering of the component.
|
||||
*/
|
||||
override fun getMainComponentName(): String = "main"
|
||||
|
||||
/**
|
||||
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
|
||||
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
|
||||
*/
|
||||
override fun createReactActivityDelegate(): ReactActivityDelegate {
|
||||
return ReactActivityDelegateWrapper(
|
||||
this,
|
||||
BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
|
||||
object : DefaultReactActivityDelegate(
|
||||
this,
|
||||
mainComponentName,
|
||||
fabricEnabled
|
||||
){})
|
||||
}
|
||||
|
||||
/**
|
||||
* Align the back button behavior with Android S
|
||||
* where moving root activities to background instead of finishing activities.
|
||||
* @see <a href="https://developer.android.com/reference/android/app/Activity#onBackPressed()">onBackPressed</a>
|
||||
*/
|
||||
override fun invokeDefaultOnBackPressed() {
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
|
||||
if (!moveTaskToBack(false)) {
|
||||
// For non-root activities, use the default implementation to finish them.
|
||||
super.invokeDefaultOnBackPressed()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Use the default back button implementation on Android S
|
||||
// because it's doing more than [Activity.moveTaskToBack] in fact.
|
||||
super.invokeDefaultOnBackPressed()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.floegger.timetoleave
|
||||
|
||||
import android.app.Application
|
||||
import android.content.res.Configuration
|
||||
|
||||
import com.facebook.react.PackageList
|
||||
import com.facebook.react.ReactApplication
|
||||
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
|
||||
import com.facebook.react.ReactNativeHost
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.ReactHost
|
||||
import com.facebook.react.common.ReleaseLevel
|
||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
|
||||
import com.facebook.react.defaults.DefaultReactNativeHost
|
||||
|
||||
import expo.modules.ApplicationLifecycleDispatcher
|
||||
import expo.modules.ReactNativeHostWrapper
|
||||
|
||||
class MainApplication : Application(), ReactApplication {
|
||||
|
||||
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
|
||||
this,
|
||||
object : DefaultReactNativeHost(this) {
|
||||
override fun getPackages(): List<ReactPackage> =
|
||||
PackageList(this).packages.apply {
|
||||
// Packages that cannot be autolinked yet can be added manually here, for example:
|
||||
// add(MyReactNativePackage())
|
||||
}
|
||||
|
||||
override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
|
||||
|
||||
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
|
||||
|
||||
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
|
||||
}
|
||||
)
|
||||
|
||||
override val reactHost: ReactHost
|
||||
get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
DefaultNewArchitectureEntryPoint.releaseLevel = try {
|
||||
ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
|
||||
} catch (e: IllegalArgumentException) {
|
||||
ReleaseLevel.STABLE
|
||||
}
|
||||
loadReactNative(this)
|
||||
ApplicationLifecycleDispatcher.onApplicationCreate(this)
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 65 KiB |
@@ -0,0 +1,6 @@
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/splashscreen_background"/>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splashscreen_logo"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Copyright (C) 2014 The Android Open Source Project
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
|
||||
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
|
||||
android:insetTop="@dimen/abc_edit_text_inset_top_material"
|
||||
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"
|
||||
>
|
||||
|
||||
<selector>
|
||||
<!--
|
||||
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
|
||||
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
|
||||
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
|
||||
|
||||
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
|
||||
|
||||
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
|
||||
-->
|
||||
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
|
||||
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
|
||||
</selector>
|
||||
|
||||
</inset>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1 @@
|
||||
<resources/>
|
||||
@@ -0,0 +1,5 @@
|
||||
<resources>
|
||||
<color name="splashscreen_background">#FFFFFF</color>
|
||||
<color name="colorPrimary">#023c69</color>
|
||||
<color name="colorPrimaryDark">#ffffff</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">time-to-leave</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,11 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="android:enforceNavigationBarContrast" tools:targetApi="29">true</item>
|
||||
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="android:statusBarColor">#ffffff</item>
|
||||
</style>
|
||||
<style name="Theme.App.SplashScreen" parent="AppTheme">
|
||||
<item name="android:windowBackground">@drawable/ic_launcher_background</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,24 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath('com.android.tools.build:gradle')
|
||||
classpath('com.facebook.react:react-native-gradle-plugin')
|
||||
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url 'https://www.jitpack.io' }
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: "expo-root-project"
|
||||
apply plugin: "com.facebook.react.rootproject"
|
||||
@@ -0,0 +1,65 @@
|
||||
# Project-wide Gradle settings.
|
||||
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
|
||||
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
org.gradle.parallel=true
|
||||
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
|
||||
# Enable AAPT2 PNG crunching
|
||||
android.enablePngCrunchInReleaseBuilds=true
|
||||
|
||||
# Use this property to specify which architecture you want to build.
|
||||
# You can also override it from the CLI using
|
||||
# ./gradlew <task> -PreactNativeArchitectures=x86_64
|
||||
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
|
||||
|
||||
# Use this property to enable support to the new architecture.
|
||||
# This will allow you to use TurboModules and the Fabric render in
|
||||
# your application. You should enable this flag either if you want
|
||||
# to write custom TurboModules/Fabric components OR use libraries that
|
||||
# are providing them.
|
||||
newArchEnabled=true
|
||||
|
||||
# Use this property to enable or disable the Hermes JS engine.
|
||||
# If set to false, you will be using JSC instead.
|
||||
hermesEnabled=true
|
||||
|
||||
# Use this property to enable edge-to-edge display support.
|
||||
# This allows your app to draw behind system bars for an immersive UI.
|
||||
# Note: Only works with ReactActivity and should not be used with custom Activity.
|
||||
edgeToEdgeEnabled=true
|
||||
|
||||
# Enable GIF support in React Native images (~200 B increase)
|
||||
expo.gif.enabled=true
|
||||
# Enable webp support in React Native images (~85 KB increase)
|
||||
expo.webp.enabled=true
|
||||
# Enable animated webp support (~3.4 MB increase)
|
||||
# Disabled by default because iOS doesn't support animated webp
|
||||
expo.webp.animated=false
|
||||
|
||||
# Enable network inspector
|
||||
EX_DEV_CLIENT_NETWORK_INSPECTOR=true
|
||||
|
||||
# Use legacy packaging to compress native libraries in the resulting APK.
|
||||
expo.useLegacyPackaging=false
|
||||
|
||||
# Specifies whether the app is configured to use edge-to-edge via the app config or plugin
|
||||
# WARNING: This property has been deprecated and will be removed in Expo SDK 55. Use `edgeToEdgeEnabled` or `react.edgeToEdgeEnabled` to determine whether the project is using edge-to-edge.
|
||||
expo.edgeToEdgeEnabled=true
|
||||
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,251 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,39 @@
|
||||
pluginManagement {
|
||||
def reactNativeGradlePlugin = new File(
|
||||
providers.exec {
|
||||
workingDir(rootDir)
|
||||
commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
|
||||
}.standardOutput.asText.get().trim()
|
||||
).getParentFile().absolutePath
|
||||
includeBuild(reactNativeGradlePlugin)
|
||||
|
||||
def expoPluginsPath = new File(
|
||||
providers.exec {
|
||||
workingDir(rootDir)
|
||||
commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
|
||||
}.standardOutput.asText.get().trim(),
|
||||
"../android/expo-gradle-plugin"
|
||||
).absolutePath
|
||||
includeBuild(expoPluginsPath)
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("com.facebook.react.settings")
|
||||
id("expo-autolinking-settings")
|
||||
}
|
||||
|
||||
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
|
||||
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
|
||||
ex.autolinkLibrariesFromCommand()
|
||||
} else {
|
||||
ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
|
||||
}
|
||||
}
|
||||
expoAutolinking.useExpoModules()
|
||||
|
||||
rootProject.name = 'time-to-leave'
|
||||
|
||||
expoAutolinking.useExpoVersionCatalog()
|
||||
|
||||
include ':app'
|
||||
includeBuild(expoAutolinking.reactNativeGradlePlugin)
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"expo": {
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "78e8f448-5ce1-4d2e-b589-481c67cb7aef"
|
||||
}
|
||||
},
|
||||
"ios": {
|
||||
"bundleIdentifier": "com.floegger.timetoleave",
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"package": "com.floegger.timetoleave"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['expo'],
|
||||
rules: {
|
||||
'react-native/no-inline-styles': 'off',
|
||||
},
|
||||
ignorePatterns: ['node_modules/', '.expo/', 'dist/'],
|
||||
};
|
||||
@@ -5,12 +5,12 @@
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"userInterfaceStyle": "dark",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#007AFF"
|
||||
"backgroundColor": "#090816"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
@@ -23,7 +23,7 @@
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#007AFF"
|
||||
"backgroundColor": "#090816"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false,
|
||||
@@ -31,7 +31,8 @@
|
||||
"permissions": [
|
||||
"android.permission.ACCESS_FINE_LOCATION",
|
||||
"android.permission.POST_NOTIFICATIONS",
|
||||
"android.permission.INTERNET"
|
||||
"android.permission.INTERNET",
|
||||
"android.permission.ACCESS_COARSE_LOCATION"
|
||||
]
|
||||
},
|
||||
"web": {
|
||||
@@ -41,6 +42,12 @@
|
||||
"expo-location",
|
||||
"expo-notifications"
|
||||
],
|
||||
"privacyPolicyUrl": "https://timetoleave.app/privacy-policy"
|
||||
"privacyPolicyUrl": "https://timetoleave.app/privacy-policy",
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "2467d09e-f838-404b-b5a9-14d48ac76bec"
|
||||
}
|
||||
},
|
||||
"owner": "floegger"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 871 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 871 KiB |
|
After Width: | Height: | Size: 871 KiB |
@@ -2,11 +2,10 @@ module.exports = {
|
||||
preset: 'jest-expo',
|
||||
testMatch: ['**/__tests__/**/*.test.[jt]s?(x)'],
|
||||
moduleNameMapper: {
|
||||
'^react$': '<rootDir>/node_modules/react',
|
||||
'^react-test-renderer$': '<rootDir>/node_modules/react-test-renderer',
|
||||
'^react-native-safe-area-context$': '<rootDir>/node_modules/react-native-safe-area-context',
|
||||
'^react-native-screens$': '<rootDir>/node_modules/react-native-screens',
|
||||
'^@react-native-async-storage/async-storage$':
|
||||
'<rootDir>/node_modules/@react-native-async-storage/async-storage',
|
||||
'@react-native-async-storage/async-storage/jest/async-storage-mock',
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|@sentry/.*|@fortawesome/.*)',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -5,34 +5,39 @@
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
|
||||
"lint": "eslint src/",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||
"@fortawesome/react-native-fontawesome": "^1.0.0",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-navigation/native": "^7.2.4",
|
||||
"@react-navigation/native-stack": "^7.14.14",
|
||||
"@timetoleave/api-client": "*",
|
||||
"@timetoleave/core": "*",
|
||||
"expo": "~54.0.33",
|
||||
"expo": "~54.0.34",
|
||||
"expo-calendar": "~15.0.8",
|
||||
"expo-dev-client": "~6.0.21",
|
||||
"expo-location": "~19.0.8",
|
||||
"expo-notifications": "~0.32.17",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-maps": "^1.20.0",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0"
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "^15.15.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@testing-library/react-native": "^13.3.3",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/react": "^19",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/react": "~19.1.10",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"jest": "^29.7.0",
|
||||
"jest-expo": "~54.0.0",
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
setCachedJourneys,
|
||||
getCachedJourneys,
|
||||
setCachedBikeRoute,
|
||||
getCachedBikeRoute,
|
||||
setCachedWalkRoute,
|
||||
getCachedWalkRoute,
|
||||
clearApiCache,
|
||||
} from '../store/apiCache';
|
||||
import type { Journey, BikeRoute, WalkRoute } from '@timetoleave/core';
|
||||
|
||||
const mockJourneys: Journey[] = [
|
||||
{
|
||||
id: 'journey-1',
|
||||
sD: new Date('2099-01-01T08:00:00Z'),
|
||||
rD: new Date('2099-01-01T08:00:00Z'),
|
||||
sA: new Date('2099-01-01T09:00:00Z'),
|
||||
rA: new Date('2099-01-01T09:00:00Z'),
|
||||
delay: 0,
|
||||
platform: '1',
|
||||
changes: 0,
|
||||
trains: ['S1'],
|
||||
cancelled: false,
|
||||
},
|
||||
];
|
||||
|
||||
const mockBikeRoute: BikeRoute = {
|
||||
distance: 5000,
|
||||
duration: 1200,
|
||||
steps: [{ name: 'Step 1', distance: 5000, duration: 1200, instruction: 'Ride' }],
|
||||
};
|
||||
|
||||
const mockWalkRoute: WalkRoute = {
|
||||
distance: 700,
|
||||
duration: 600,
|
||||
steps: [{ name: 'Step 1', distance: 700, duration: 600, instruction: 'Walk' }],
|
||||
};
|
||||
|
||||
describe('apiCache', () => {
|
||||
beforeEach(async () => {
|
||||
await clearApiCache();
|
||||
});
|
||||
|
||||
it('stores and retrieves journeys', async () => {
|
||||
await setCachedJourneys('event-1', mockJourneys);
|
||||
const retrieved = await getCachedJourneys('event-1');
|
||||
expect(retrieved).toHaveLength(1);
|
||||
expect(retrieved![0].id).toBe('journey-1');
|
||||
expect(retrieved![0].rD).toEqual(new Date('2099-01-01T08:00:00Z'));
|
||||
});
|
||||
|
||||
it('stores and retrieves bike routes', async () => {
|
||||
await setCachedBikeRoute('event-1', mockBikeRoute);
|
||||
const retrieved = await getCachedBikeRoute('event-1');
|
||||
expect(retrieved).toEqual(mockBikeRoute);
|
||||
});
|
||||
|
||||
it('stores and retrieves walk routes', async () => {
|
||||
await setCachedWalkRoute('event-1', mockWalkRoute);
|
||||
const retrieved = await getCachedWalkRoute('event-1');
|
||||
expect(retrieved).toEqual(mockWalkRoute);
|
||||
});
|
||||
|
||||
it('returns null for missing cache entries', async () => {
|
||||
expect(await getCachedJourneys('missing')).toBeNull();
|
||||
expect(await getCachedBikeRoute('missing')).toBeNull();
|
||||
expect(await getCachedWalkRoute('missing')).toBeNull();
|
||||
});
|
||||
|
||||
it('expires entries older than 30 minutes', async () => {
|
||||
// Use jest fake timers to simulate 31 minutes passing
|
||||
jest.useFakeTimers();
|
||||
await setCachedBikeRoute('event-1', mockBikeRoute);
|
||||
jest.advanceTimersByTime(31 * 60 * 1000);
|
||||
const retrieved = await getCachedBikeRoute('event-1');
|
||||
expect(retrieved).toBeNull();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('clears all cache entries', async () => {
|
||||
await setCachedJourneys('event-1', mockJourneys);
|
||||
await setCachedBikeRoute('event-2', mockBikeRoute);
|
||||
await clearApiCache();
|
||||
expect(await getCachedJourneys('event-1')).toBeNull();
|
||||
expect(await getCachedBikeRoute('event-2')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -91,7 +91,8 @@ describe('calendar service', () => {
|
||||
|
||||
const result = await fetchNativeEvents(startDate, endDate);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
// Only the event with a location is returned; events without a location are filtered out
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
id: 'evt1',
|
||||
title: 'Team Meeting',
|
||||
@@ -99,13 +100,6 @@ describe('calendar service', () => {
|
||||
eventTime: new Date('2025-01-15T10:00:00'),
|
||||
source: 'native:cal1',
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
id: 'evt2',
|
||||
title: 'Dentist',
|
||||
destination: '',
|
||||
eventTime: new Date('2025-01-20T14:00:00'),
|
||||
source: 'native:cal2',
|
||||
});
|
||||
|
||||
expect(mockCalendar.getEventsAsync).toHaveBeenCalledWith(
|
||||
['cal1', 'cal2'],
|
||||
@@ -114,7 +108,33 @@ describe('calendar service', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('handles events with missing title or startDate', async () => {
|
||||
it('filters out events with no location', async () => {
|
||||
mockCalendar.requestCalendarPermissionsAsync.mockResolvedValue({ status: 'granted' as Calendar.PermissionStatus, granted: true, expires: 'never' as const, canAskAgain: true });
|
||||
mockCalendar.isAvailableAsync.mockResolvedValue(true);
|
||||
mockCalendar.getCalendarsAsync.mockResolvedValue([{ id: 'cal1', title: 'Default' }] as Calendar.Calendar[]);
|
||||
mockCalendar.getEventsAsync.mockResolvedValue([
|
||||
{
|
||||
id: 'evt1',
|
||||
calendarId: 'cal1',
|
||||
title: 'No Location Event',
|
||||
location: null,
|
||||
startDate: new Date('2025-01-15T10:00:00'),
|
||||
},
|
||||
{
|
||||
id: 'evt2',
|
||||
calendarId: 'cal1',
|
||||
title: 'Empty Location Event',
|
||||
location: ' ',
|
||||
startDate: new Date('2025-01-16T10:00:00'),
|
||||
},
|
||||
] as Calendar.Event[]);
|
||||
|
||||
const result = await fetchNativeEvents(new Date(), new Date());
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles events with missing title', async () => {
|
||||
mockCalendar.requestCalendarPermissionsAsync.mockResolvedValue({ status: 'granted' as Calendar.PermissionStatus, granted: true, expires: 'never' as const, canAskAgain: true });
|
||||
mockCalendar.isAvailableAsync.mockResolvedValue(true);
|
||||
mockCalendar.getCalendarsAsync.mockResolvedValue([{ id: 'cal1', title: 'Default' }] as Calendar.Calendar[]);
|
||||
@@ -123,7 +143,7 @@ describe('calendar service', () => {
|
||||
id: 'evt1',
|
||||
calendarId: 'cal1',
|
||||
title: null as unknown as string,
|
||||
location: null,
|
||||
location: 'Wien Hbf',
|
||||
startDate: null as unknown as string | Date,
|
||||
},
|
||||
] as Calendar.Event[]);
|
||||
@@ -132,7 +152,7 @@ describe('calendar service', () => {
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].title).toBe('Untitled Event');
|
||||
expect(result[0].destination).toBe('');
|
||||
expect(result[0].destination).toBe('Wien Hbf');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
// Tests for core utilities
|
||||
import { calculateCountdown } from '@timetoleave/core';
|
||||
import { calculateCountdown, rankJourneys } from '@timetoleave/core';
|
||||
import type { Journey } from '@timetoleave/core';
|
||||
|
||||
function journey(overrides: Partial<Journey>): Journey {
|
||||
return {
|
||||
id: 'journey',
|
||||
sD: new Date('2025-01-01T10:00:00Z'),
|
||||
rD: new Date('2025-01-01T10:00:00Z'),
|
||||
sA: new Date('2025-01-01T11:00:00Z'),
|
||||
rA: new Date('2025-01-01T11:00:00Z'),
|
||||
delay: 0,
|
||||
platform: '1',
|
||||
changes: 0,
|
||||
trains: ['REX1 -> Wr. Neustadt Hbf'],
|
||||
cancelled: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('core utilities', () => {
|
||||
describe('calculateCountdown', () => {
|
||||
@@ -77,4 +94,50 @@ describe('core utilities', () => {
|
||||
expect(result.color).toBe('green');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rankJourneys', () => {
|
||||
it('ranks the connection closest to the target arrival highest', () => {
|
||||
const target = new Date('2025-01-01T11:00:00Z');
|
||||
// All journeys have the same changes and similar duration so only
|
||||
// arrival fit influences the ranking.
|
||||
const early = journey({
|
||||
id: 'early',
|
||||
sD: new Date('2025-01-01T10:00:00Z'),
|
||||
rD: new Date('2025-01-01T10:00:00Z'),
|
||||
sA: new Date('2025-01-01T10:40:00Z'),
|
||||
rA: new Date('2025-01-01T10:40:00Z'),
|
||||
changes: 0,
|
||||
});
|
||||
const close = journey({
|
||||
id: 'close',
|
||||
sD: new Date('2025-01-01T10:00:00Z'),
|
||||
rD: new Date('2025-01-01T10:00:00Z'),
|
||||
sA: new Date('2025-01-01T10:58:00Z'),
|
||||
rA: new Date('2025-01-01T10:58:00Z'),
|
||||
changes: 0,
|
||||
});
|
||||
const late = journey({
|
||||
id: 'late',
|
||||
sD: new Date('2025-01-01T10:00:00Z'),
|
||||
rD: new Date('2025-01-01T10:00:00Z'),
|
||||
sA: new Date('2025-01-01T11:05:00Z'),
|
||||
rA: new Date('2025-01-01T11:05:00Z'),
|
||||
changes: 0,
|
||||
});
|
||||
|
||||
const ranked = rankJourneys([early, late, close], target);
|
||||
|
||||
expect(ranked[0].journey.id).toBe('close');
|
||||
});
|
||||
|
||||
it('uses directness and duration as tie breakers after arrival fit', () => {
|
||||
const target = new Date('2025-01-01T11:00:00Z');
|
||||
const oneChange = journey({ id: 'change', changes: 1 });
|
||||
const direct = journey({ id: 'direct', changes: 0 });
|
||||
|
||||
const ranked = rankJourneys([oneChange, direct], target);
|
||||
|
||||
expect(ranked[0].journey.id).toBe('direct');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,11 +137,16 @@ describe('eventStore', () => {
|
||||
});
|
||||
|
||||
describe('origin station', () => {
|
||||
it('should load null when no origin exists', async () => {
|
||||
it('should load the default origin when no saved origin exists', async () => {
|
||||
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
const station = await loadOriginStation();
|
||||
expect(station).toBeNull();
|
||||
expect(station).toEqual({
|
||||
name: 'Goethegasse 36, 2340 Moedling',
|
||||
extId: '1231701',
|
||||
lat: 48.0806926,
|
||||
lng: 16.2908052,
|
||||
});
|
||||
});
|
||||
|
||||
it('should load origin station from AsyncStorage', async () => {
|
||||
|
||||
@@ -4,12 +4,14 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import type { RouteProp } from '@react-navigation/native';
|
||||
import { EventListScreen } from '../screens/EventListScreen';
|
||||
import { AddEventScreen } from '../screens/AddEventScreen';
|
||||
import { loadEvents } from '../store/eventStore';
|
||||
import { loadEvents, loadNotificationSettings, loadOriginStation } from '../store/eventStore';
|
||||
import { calculateCountdown } from '@timetoleave/core';
|
||||
|
||||
// Mock the store and utilities
|
||||
jest.mock('../store/eventStore', () => ({
|
||||
loadEvents: jest.fn(),
|
||||
loadOriginStation: jest.fn(),
|
||||
loadNotificationSettings: jest.fn(),
|
||||
removeEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
@@ -18,12 +20,86 @@ jest.mock('@timetoleave/core', () => ({
|
||||
calculateCountdown: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../hooks/useColors', () => ({
|
||||
useColors: () => ({
|
||||
background: '#000',
|
||||
card: '#111',
|
||||
text: '#fff',
|
||||
subtext: '#aaa',
|
||||
accent: '#8B5CF6',
|
||||
border: '#333',
|
||||
delete: '#ff3b30',
|
||||
error: '#ff3b30',
|
||||
overlay: '#111',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../hooks/useDestinationStation', () => ({
|
||||
useDestinationStation: () => ({
|
||||
station: { name: 'Ziel Bahnhof', extId: '8103000', lat: 48.2, lng: 16.3 },
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../hooks/useGeocode', () => ({
|
||||
useGeocode: () => ({
|
||||
coords: { lat: 48.21, lng: 16.31, display_name: 'Test Destination' },
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../hooks/useWalkRoute', () => ({
|
||||
useWalkRoute: () => ({
|
||||
walkRoute: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../hooks/useOriginStationWalk', () => ({
|
||||
useOriginStationWalk: () => ({
|
||||
station: { name: 'Mödling Bahnhof', extId: '1231701', lat: 48.085, lng: 16.296 },
|
||||
walkRoute: { distance: 700, duration: 600, steps: [] },
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../services/api', () => ({
|
||||
api: {
|
||||
findStationByExtId: jest.fn().mockResolvedValue({
|
||||
name: 'Mödling Bahnhof',
|
||||
extId: '1231701',
|
||||
lat: 48.085,
|
||||
lng: 16.296,
|
||||
}),
|
||||
searchJourneys: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'journey-1',
|
||||
sD: new Date('2099-01-01T08:00:00Z'),
|
||||
rD: new Date('2099-01-01T08:00:00Z'),
|
||||
sA: new Date('2099-01-01T09:00:00Z'),
|
||||
rA: new Date('2099-01-01T09:00:00Z'),
|
||||
delay: 0,
|
||||
platform: '1',
|
||||
changes: 0,
|
||||
trains: ['S1 -> Wien'],
|
||||
cancelled: false,
|
||||
},
|
||||
]),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock useFocusEffect so EventListScreen can render without NavigationContainer
|
||||
jest.mock('@react-navigation/native', () => ({
|
||||
...jest.requireActual('@react-navigation/native'),
|
||||
useFocusEffect: (callback: () => void) => {
|
||||
// Execute the callback immediately so the component loads data
|
||||
callback();
|
||||
const React = jest.requireActual('react');
|
||||
React.useEffect(() => {
|
||||
callback();
|
||||
}, [callback]);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -67,6 +143,19 @@ const mockRouteAddEvent = { name: 'AddEvent' as const, params: undefined } as un
|
||||
describe('EventListScreen', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(loadOriginStation as jest.Mock).mockResolvedValue({
|
||||
name: 'Mödling Bahnhof',
|
||||
extId: '1231701',
|
||||
lat: 48.08,
|
||||
lng: 16.29,
|
||||
});
|
||||
(loadNotificationSettings as jest.Mock).mockResolvedValue({
|
||||
bufferMinutes: 30,
|
||||
enabled: true,
|
||||
arrivalBufferMinutes: 5,
|
||||
showWalkingOption: true,
|
||||
showBikeOption: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render empty state when no events', async () => {
|
||||
@@ -77,7 +166,7 @@ describe('EventListScreen', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Keine Termine')).toBeTruthy();
|
||||
expect(getByText('No upcoming events')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,7 +176,7 @@ describe('EventListScreen', () => {
|
||||
id: 'test-1',
|
||||
title: 'Test Event',
|
||||
destination: 'Test Destination',
|
||||
eventTime: new Date('2025-01-01T10:00:00Z'),
|
||||
eventTime: new Date('2099-01-01T10:00:00Z'),
|
||||
source: 'manual',
|
||||
}
|
||||
];
|
||||
@@ -115,7 +204,7 @@ describe('EventListScreen', () => {
|
||||
id: 'test-1',
|
||||
title: 'Test Event',
|
||||
destination: 'Test Destination',
|
||||
eventTime: new Date('2025-01-01T10:00:00Z'),
|
||||
eventTime: new Date('2099-01-01T10:00:00Z'),
|
||||
source: 'manual',
|
||||
}
|
||||
];
|
||||
@@ -147,12 +236,12 @@ describe('AddEventScreen', () => {
|
||||
<AddEventScreen navigation={createMockNavigationProp<RootStack, 'AddEvent'>()} route={mockRouteAddEvent} />
|
||||
);
|
||||
|
||||
expect(getByPlaceholderText('z.B. Team Meeting')).toBeTruthy();
|
||||
expect(getByPlaceholderText('z.B. Wien, Donau-City')).toBeTruthy();
|
||||
expect(getByPlaceholderText('JJJJ-MM-TT')).toBeTruthy();
|
||||
expect(getByPlaceholderText('SS:MM')).toBeTruthy();
|
||||
expect(getByText('Speichern')).toBeTruthy();
|
||||
expect(getByText('Abbrechen')).toBeTruthy();
|
||||
expect(getByPlaceholderText('e.g. Team Meeting')).toBeTruthy();
|
||||
expect(getByPlaceholderText('e.g. Technikum Wien')).toBeTruthy();
|
||||
expect(getByPlaceholderText('YYYY-MM-DD')).toBeTruthy();
|
||||
expect(getByPlaceholderText('HH:MM')).toBeTruthy();
|
||||
expect(getByText('Save')).toBeTruthy();
|
||||
expect(getByText('Cancel')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show validation errors', () => {
|
||||
@@ -161,11 +250,11 @@ describe('AddEventScreen', () => {
|
||||
);
|
||||
|
||||
// Try to save without filling form
|
||||
const saveButton = getByText('Speichern');
|
||||
const saveButton = getByText('Save');
|
||||
fireEvent.press(saveButton);
|
||||
|
||||
// Should show error text
|
||||
expect(getByText('Titel erforderlich')).toBeTruthy();
|
||||
expect(getByText('Title required')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should validate date format', () => {
|
||||
@@ -174,15 +263,15 @@ describe('AddEventScreen', () => {
|
||||
);
|
||||
|
||||
// Fill in all required fields except date format is invalid
|
||||
fireEvent.changeText(getByPlaceholderText('z.B. Team Meeting'), 'Meeting');
|
||||
fireEvent.changeText(getByPlaceholderText('z.B. Wien, Donau-City'), 'Wien');
|
||||
fireEvent.changeText(getByPlaceholderText('JJJJ-MM-TT'), 'invalid-date');
|
||||
fireEvent.changeText(getByPlaceholderText('SS:MM'), '12:00');
|
||||
fireEvent.changeText(getByPlaceholderText('e.g. Team Meeting'), 'Meeting');
|
||||
fireEvent.changeText(getByPlaceholderText('e.g. Technikum Wien'), 'Wien');
|
||||
fireEvent.changeText(getByPlaceholderText('YYYY-MM-DD'), 'invalid-date');
|
||||
fireEvent.changeText(getByPlaceholderText('HH:MM'), '12:00');
|
||||
|
||||
const saveButton = getByText('Speichern');
|
||||
const saveButton = getByText('Save');
|
||||
fireEvent.press(saveButton);
|
||||
|
||||
expect(getByText('Ungültiges Datum')).toBeTruthy();
|
||||
expect(getByText('Invalid date')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should validate future date', () => {
|
||||
@@ -191,14 +280,14 @@ describe('AddEventScreen', () => {
|
||||
);
|
||||
|
||||
// Fill in all required fields with a past date
|
||||
fireEvent.changeText(getByPlaceholderText('z.B. Team Meeting'), 'Meeting');
|
||||
fireEvent.changeText(getByPlaceholderText('z.B. Wien, Donau-City'), 'Wien');
|
||||
fireEvent.changeText(getByPlaceholderText('JJJJ-MM-TT'), '2020-01-01');
|
||||
fireEvent.changeText(getByPlaceholderText('SS:MM'), '12:00');
|
||||
fireEvent.changeText(getByPlaceholderText('e.g. Team Meeting'), 'Meeting');
|
||||
fireEvent.changeText(getByPlaceholderText('e.g. Technikum Wien'), 'Wien');
|
||||
fireEvent.changeText(getByPlaceholderText('YYYY-MM-DD'), '2020-01-01');
|
||||
fireEvent.changeText(getByPlaceholderText('HH:MM'), '12:00');
|
||||
|
||||
const saveButton = getByText('Speichern');
|
||||
const saveButton = getByText('Save');
|
||||
fireEvent.press(saveButton);
|
||||
|
||||
expect(getByText('Datum muss in der Zukunft liegen')).toBeTruthy();
|
||||
expect(getByText('Date must be in the future')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
declare module '*.png' {
|
||||
const value: number;
|
||||
export default value;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome';
|
||||
import { faClock, faRuler } from '@fortawesome/free-solid-svg-icons';
|
||||
import { formatDuration, formatDistance } from '@timetoleave/core';
|
||||
import type { BikeRoute, Station } from '@timetoleave/core';
|
||||
import type { AppColors } from '../hooks/useColors';
|
||||
import { RouteMap } from './RouteMap';
|
||||
|
||||
/** Props for the bike route information section shown on event detail. */
|
||||
interface Props {
|
||||
bikeRoute: BikeRoute | null;
|
||||
loading: boolean;
|
||||
origin: Station | null;
|
||||
colors: AppColors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays cycling route duration, distance, and an interactive map for the
|
||||
* event's origin → destination leg. Shows contextual empty states when no
|
||||
* origin is set or no route is available.
|
||||
*/
|
||||
export function BikeSection({ bikeRoute, loading, origin, colors }: Props) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Bike route</Text>
|
||||
|
||||
{loading ? (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="small" color={colors.accent} />
|
||||
<Text style={[styles.hint, { color: colors.subtext }]}>Loading bike route…</Text>
|
||||
</View>
|
||||
) : bikeRoute ? (
|
||||
<View style={[styles.card, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.labelRow}>
|
||||
<FontAwesomeIcon icon={faClock} size={13} color={colors.text} />
|
||||
<Text style={[styles.label, { color: colors.text }]}>Dauer</Text>
|
||||
</View>
|
||||
<Text style={[styles.value, { color: colors.accent }]}>{formatDuration(bikeRoute.duration)}</Text>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.labelRow}>
|
||||
<FontAwesomeIcon icon={faRuler} size={13} color={colors.text} />
|
||||
<Text style={[styles.label, { color: colors.text }]}>Distanz</Text>
|
||||
</View>
|
||||
<Text style={[styles.value, { color: colors.accent }]}>{formatDistance(bikeRoute.distance)}</Text>
|
||||
</View>
|
||||
{bikeRoute.geometry && (
|
||||
<RouteMap geometry={bikeRoute.geometry} colors={colors} mode="bike" />
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<Text style={[styles.empty, { color: colors.subtext }]}>
|
||||
{origin ? 'No bike route available' : 'Set origin station'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { padding: 20 },
|
||||
sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 12 },
|
||||
centered: { alignItems: 'center', gap: 8 },
|
||||
hint: { fontSize: 15, marginTop: 12 },
|
||||
empty: { fontSize: 14 },
|
||||
card: { borderRadius: 10, padding: 14, borderWidth: 1 },
|
||||
row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 6 },
|
||||
labelRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||
label: { fontSize: 15, fontWeight: '500' },
|
||||
value: { fontSize: 15, fontWeight: '600' },
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import type { Event } from '@timetoleave/core';
|
||||
import type { AppColors } from '../hooks/useColors';
|
||||
|
||||
/** Props for the event detail header showing title, destination, times, and buffers. */
|
||||
interface Props {
|
||||
event: Event;
|
||||
leaveByTime: Date | null;
|
||||
arrivalBufferMinutes: number;
|
||||
colors: AppColors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the event title, destination, leave-by time, data source, and a
|
||||
* three-column grid with leave-by time, arrive-by time, and buffer.
|
||||
*/
|
||||
export function EventHeader({ event, leaveByTime, arrivalBufferMinutes, colors }: Props) {
|
||||
const arriveByTime = new Date(event.eventTime.getTime() - arrivalBufferMinutes * 60_000);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.card }]}>
|
||||
<Text style={[styles.title, { color: colors.text }]}>{event.title}</Text>
|
||||
<Text style={[styles.destination, { color: colors.subtext }]}>{event.destination}</Text>
|
||||
<Text style={[styles.leaveTime, { color: leaveByTime ? colors.accent : colors.subtext }]}>
|
||||
{leaveByTime
|
||||
? `Losgehen um ${leaveByTime.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}`
|
||||
: 'Losgehzeit wird berechnet'}
|
||||
</Text>
|
||||
<Text style={[styles.source, { color: colors.subtext }]}>Quelle: {event.source}</Text>
|
||||
|
||||
<View style={[styles.infoGrid, { borderTopColor: colors.border }]}>
|
||||
<View style={styles.infoBox}>
|
||||
<Text style={[styles.infoLabel, { color: colors.subtext }]}>Losgehen um</Text>
|
||||
<Text style={[styles.infoValue, { color: colors.text }]}>
|
||||
{leaveByTime
|
||||
? leaveByTime.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })
|
||||
: '—'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.infoBox}>
|
||||
<Text style={[styles.infoLabel, { color: colors.subtext }]}>Ankommen um</Text>
|
||||
<Text style={[styles.infoValue, { color: colors.text }]}>
|
||||
{arriveByTime.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.infoBox}>
|
||||
<Text style={[styles.infoLabel, { color: colors.subtext }]}>Puffer</Text>
|
||||
<Text style={[styles.infoValue, { color: colors.text }]}>{arrivalBufferMinutes} min</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { padding: 20, marginBottom: 12 },
|
||||
title: { fontSize: 22, fontWeight: '700' },
|
||||
destination: { fontSize: 16, marginTop: 4 },
|
||||
leaveTime: { fontSize: 18, fontWeight: '700', marginTop: 10 },
|
||||
source: { fontSize: 12, marginTop: 4 },
|
||||
infoGrid: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 16,
|
||||
paddingTop: 16,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
infoBox: { alignItems: 'center' },
|
||||
infoLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
infoValue: { fontSize: 16, fontWeight: '700', marginTop: 4 },
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { formatDuration, formatDistance, rankJourneys } from '@timetoleave/core';
|
||||
import type { Journey, Station, WalkRoute } from '@timetoleave/core';
|
||||
import type { AppColors } from '../hooks/useColors';
|
||||
import { RouteMap } from './RouteMap';
|
||||
|
||||
/** Props for the train journey list and optional final walk leg. */
|
||||
interface Props {
|
||||
journeys: Journey[];
|
||||
destStationLoading: boolean;
|
||||
walkRoute: WalkRoute | null;
|
||||
loadingWalk: boolean;
|
||||
showWalkingOption: boolean;
|
||||
eventTime: Date;
|
||||
arrivalBufferMinutes: number;
|
||||
origin: Station | null;
|
||||
colors: AppColors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders each journey card with train lines, departure/arrival times, platform,
|
||||
* transfer count, and delay/cancellation badges. Cards that arrive too late are
|
||||
* visually dimmed and bordered in red. Optionally shows the final walking leg
|
||||
* from the destination station to the event location.
|
||||
*/
|
||||
export function JourneyList({
|
||||
journeys,
|
||||
destStationLoading,
|
||||
walkRoute,
|
||||
loadingWalk,
|
||||
showWalkingOption,
|
||||
eventTime,
|
||||
arrivalBufferMinutes,
|
||||
origin,
|
||||
colors,
|
||||
}: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const walkDurationMs = showWalkingOption ? (walkRoute?.duration ?? 0) * 1000 : 0;
|
||||
const targetArrivalTime = new Date(eventTime.getTime() - arrivalBufferMinutes * 60_000);
|
||||
const rankedJourneys = useMemo(
|
||||
() => rankJourneys(journeys, targetArrivalTime, walkDurationMs),
|
||||
[journeys, targetArrivalTime, walkDurationMs],
|
||||
);
|
||||
const visibleJourneys = expanded ? rankedJourneys : rankedJourneys.slice(0, 1);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Train connections</Text>
|
||||
|
||||
{destStationLoading && (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="small" color={colors.accent} />
|
||||
<Text style={[styles.hint, { color: colors.subtext }]}>Resolving destination station…</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{journeys.length === 0 && !destStationLoading ? (
|
||||
<Text style={[styles.empty, { color: colors.subtext }]}>
|
||||
{origin ? 'No connections found' : 'Set origin station'}
|
||||
</Text>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
activeOpacity={rankedJourneys.length > 1 ? 0.75 : 1}
|
||||
onPress={() => rankedJourneys.length > 1 && setExpanded((current) => !current)}
|
||||
accessibilityRole={rankedJourneys.length > 1 ? 'button' : undefined}
|
||||
accessibilityLabel={expanded ? 'Show fewer train connections' : 'Show all train connections'}
|
||||
>
|
||||
{visibleJourneys.map(({ journey: j }, index) => {
|
||||
const finalArrival = new Date(j.rA.getTime() + walkDurationMs);
|
||||
const arrivesTooLate = finalArrival.getTime() > targetArrivalTime.getTime();
|
||||
const durationMinutes = Math.max(0, Math.round((j.rA.getTime() - j.rD.getTime()) / 60_000));
|
||||
|
||||
return (
|
||||
<View
|
||||
key={j.id}
|
||||
style={[
|
||||
styles.card,
|
||||
{ backgroundColor: colors.card, borderColor: arrivesTooLate ? colors.error : colors.border },
|
||||
arrivesTooLate && styles.lateCard,
|
||||
]}
|
||||
>
|
||||
<View style={styles.row}>
|
||||
<Text style={[styles.line, { color: colors.text }]} numberOfLines={2}>
|
||||
{j.trains.length > 0 ? j.trains.join(', ') : '—'}
|
||||
</Text>
|
||||
{index === 0 && (
|
||||
<Text style={[styles.bestBadge, { backgroundColor: colors.accent }]}>Top</Text>
|
||||
)}
|
||||
{j.delay > 0 && (
|
||||
<Text style={[styles.delayBadge, { backgroundColor: colors.error }]}>+{j.delay} min</Text>
|
||||
)}
|
||||
{j.cancelled && (
|
||||
<Text style={[styles.cancelBadge, { backgroundColor: colors.text }]}>Cancelled</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text style={[styles.detail, { color: colors.text }]}>
|
||||
Departure: {new Date(j.sD).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
|
||||
{' '}(Platform {j.platform || '—'})
|
||||
</Text>
|
||||
<Text style={[styles.detail, { color: colors.subtext }]}>
|
||||
Arrival: {new Date(j.sA).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
|
||||
{' '}({j.changes === 0 ? 'Direct' : `${j.changes} tr.`})
|
||||
</Text>
|
||||
<Text style={[styles.detail, { color: colors.subtext }]}>
|
||||
Duration: {durationMinutes} min
|
||||
</Text>
|
||||
{walkDurationMs > 0 && (
|
||||
<Text style={[styles.detail, { color: arrivesTooLate ? colors.error : colors.subtext }]}>
|
||||
Arrive: {finalArrival.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
|
||||
{arrivesTooLate ? ' (too late)' : ''}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{rankedJourneys.length > 1 && (
|
||||
<Text style={[styles.expandHint, { color: colors.accent }]}>
|
||||
{expanded ? 'Show fewer connections' : `Show ${rankedJourneys.length - 1} more connections`}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{showWalkingOption && walkRoute && (
|
||||
<View style={[styles.card, { backgroundColor: colors.card, borderColor: colors.border, marginTop: 10 }]}>
|
||||
<Text style={[styles.walkTitle, { color: colors.text }]}>Final walk</Text>
|
||||
<View style={styles.row}>
|
||||
<Text style={[styles.walkLabel, { color: colors.text }]}>Duration</Text>
|
||||
<Text style={[styles.walkValue, { color: colors.accent }]}>{formatDuration(walkRoute.duration)}</Text>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<Text style={[styles.walkLabel, { color: colors.text }]}>Distance</Text>
|
||||
<Text style={[styles.walkValue, { color: colors.accent }]}>{formatDistance(walkRoute.distance)}</Text>
|
||||
</View>
|
||||
{walkRoute.geometry && (
|
||||
<RouteMap geometry={walkRoute.geometry} colors={colors} mode="walk" />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{showWalkingOption && loadingWalk && (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="small" color={colors.accent} />
|
||||
<Text style={[styles.hint, { color: colors.subtext }]}>Loading walk route…</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { padding: 20 },
|
||||
sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 12 },
|
||||
centered: { alignItems: 'center', gap: 8 },
|
||||
hint: { fontSize: 15, marginTop: 12 },
|
||||
empty: { fontSize: 14 },
|
||||
card: { borderRadius: 10, padding: 14, marginBottom: 10, borderWidth: 1 },
|
||||
lateCard: { opacity: 0.55 },
|
||||
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 },
|
||||
line: { flex: 1, fontSize: 16, fontWeight: '600' },
|
||||
bestBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6, overflow: 'hidden' },
|
||||
delayBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
|
||||
cancelBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
|
||||
detail: { fontSize: 13, marginTop: 6 },
|
||||
expandHint: { fontSize: 13, fontWeight: '600', marginTop: 2, marginBottom: 12, textAlign: 'center' },
|
||||
walkTitle: { fontSize: 15, fontWeight: '600', marginBottom: 8 },
|
||||
walkLabel: { fontSize: 14, fontWeight: '500' },
|
||||
walkValue: { fontSize: 14, fontWeight: '600' },
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome';
|
||||
import { faBusSimple } from '@fortawesome/free-solid-svg-icons';
|
||||
import type { WienerLinienStop } from '@timetoleave/core';
|
||||
import type { DepartureRow } from '../hooks/useWienerLinien';
|
||||
import type { AppColors } from '../hooks/useColors';
|
||||
|
||||
/** Props for the nearby public transport stops section. */
|
||||
interface Props {
|
||||
stops: WienerLinienStop[];
|
||||
departures: DepartureRow[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
colors: AppColors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows public transport stops near the event destination and their upcoming
|
||||
* departures. Caps the departure list at 8 items to avoid overwhelming the UI.
|
||||
* Falls back to showing plain stop names when no departure data is available.
|
||||
*/
|
||||
export function NearbyStops({ stops, departures, loading, error, colors }: Props) {
|
||||
if (!loading && stops.length === 0 && !error) return null;
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.sectionTitleRow}>
|
||||
<FontAwesomeIcon icon={faBusSimple} size={16} color={colors.text} />
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Public transit near destination</Text>
|
||||
</View>
|
||||
|
||||
{loading ? (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="small" color={colors.accent} />
|
||||
<Text style={[styles.hint, { color: colors.subtext }]}>Loading stops…</Text>
|
||||
</View>
|
||||
) : departures.length > 0 ? (
|
||||
departures.slice(0, 8).map((dep, i) => (
|
||||
<View
|
||||
key={`${dep.stopId}-${dep.lineName}-${i}`}
|
||||
style={[styles.depCard, { backgroundColor: colors.card, borderColor: colors.border }]}
|
||||
>
|
||||
<View style={styles.depRow}>
|
||||
<View style={[styles.lineBadge, { backgroundColor: colors.accent }]}>
|
||||
<Text style={styles.lineBadgeText}>{dep.lineName}</Text>
|
||||
</View>
|
||||
<Text style={[styles.direction, { color: colors.text }]} numberOfLines={1}>
|
||||
{dep.direction}
|
||||
</Text>
|
||||
<Text style={[styles.minutes, { color: dep.minutes <= 2 ? colors.error : colors.accent }]}>
|
||||
{dep.minutes === 0 ? 'now' : `${dep.minutes} min`}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
stops.slice(0, 5).map((stop) => (
|
||||
<View key={stop.id} style={[styles.stopCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||
<Text style={[styles.stopName, { color: colors.text }]}>{stop.name}</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
|
||||
{error && <Text style={[styles.hint, { color: colors.subtext }]}>{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { padding: 20 },
|
||||
sectionTitleRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 12 },
|
||||
sectionTitle: { fontSize: 18, fontWeight: '600' },
|
||||
centered: { alignItems: 'center', gap: 8 },
|
||||
hint: { fontSize: 14 },
|
||||
depCard: { borderRadius: 10, padding: 10, marginBottom: 6, borderWidth: 1 },
|
||||
depRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
lineBadge: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 6, minWidth: 36, alignItems: 'center' },
|
||||
lineBadgeText: { color: '#fff', fontSize: 12, fontWeight: '700' },
|
||||
direction: { flex: 1, fontSize: 13 },
|
||||
minutes: { fontSize: 13, fontWeight: '700', minWidth: 40, textAlign: 'right' },
|
||||
stopCard: { borderRadius: 10, padding: 12, marginBottom: 8, borderWidth: 1 },
|
||||
stopName: { fontSize: 14, fontWeight: '500' },
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useMemo } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import MapView, { Marker, Polyline } from 'react-native-maps';
|
||||
import { decodePolyline } from '../utils/polyline';
|
||||
import type { AppColors } from '../hooks/useColors';
|
||||
|
||||
interface Props {
|
||||
geometry: string;
|
||||
colors: AppColors;
|
||||
mode: 'bike' | 'walk';
|
||||
}
|
||||
|
||||
function getRegionFromCoordinates(
|
||||
coords: Array<{ latitude: number; longitude: number }>,
|
||||
) {
|
||||
let minLat = Infinity;
|
||||
let maxLat = -Infinity;
|
||||
let minLng = Infinity;
|
||||
let maxLng = -Infinity;
|
||||
|
||||
for (const c of coords) {
|
||||
minLat = Math.min(minLat, c.latitude);
|
||||
maxLat = Math.max(maxLat, c.latitude);
|
||||
minLng = Math.min(minLng, c.longitude);
|
||||
maxLng = Math.max(maxLng, c.longitude);
|
||||
}
|
||||
|
||||
const latDelta = (maxLat - minLat) * 1.3; // 30 % padding
|
||||
const lngDelta = (maxLng - minLng) * 1.3;
|
||||
|
||||
return {
|
||||
latitude: (minLat + maxLat) / 2,
|
||||
longitude: (minLng + maxLng) / 2,
|
||||
latitudeDelta: Math.max(latDelta, 0.005),
|
||||
longitudeDelta: Math.max(lngDelta, 0.005),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an interactive map with the decoded route polyline and start/end
|
||||
* markers. Automatically centres and zooms to fit the entire route.
|
||||
*/
|
||||
export function RouteMap({ geometry, colors, mode }: Props) {
|
||||
const coords = useMemo(() => decodePolyline(geometry), [geometry]);
|
||||
|
||||
const region = useMemo(() => {
|
||||
if (coords.length < 2) return null;
|
||||
return getRegionFromCoordinates(coords);
|
||||
}, [coords]);
|
||||
|
||||
if (!region || coords.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const strokeColor = mode === 'bike' ? colors.accent : colors.success;
|
||||
const startCoord = coords[0];
|
||||
const endCoord = coords[coords.length - 1];
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { borderColor: colors.border }]}>
|
||||
<MapView style={styles.map} initialRegion={region} scrollEnabled={false} zoomEnabled={false} rotateEnabled={false} pitchEnabled={false}>
|
||||
<Polyline
|
||||
coordinates={coords}
|
||||
strokeColor={strokeColor}
|
||||
strokeWidth={4}
|
||||
/>
|
||||
<Marker coordinate={startCoord} pinColor={colors.accent} />
|
||||
<Marker coordinate={endCoord} pinColor={colors.success} />
|
||||
</MapView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginTop: 10,
|
||||
height: 220,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
},
|
||||
map: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useTheme } from './useTheme';
|
||||
|
||||
const DARK = {
|
||||
background: '#090816',
|
||||
card: '#17112A',
|
||||
text: '#F4F1EA',
|
||||
subtext: 'rgba(244,241,234,0.5)',
|
||||
accent: '#8B5CF6',
|
||||
border: '#38383a',
|
||||
error: '#ff453a',
|
||||
success: '#30d158',
|
||||
warning: '#ff9f0a',
|
||||
purple: '#B23CFF',
|
||||
delete: '#FF3B30',
|
||||
overlay: 'rgba(28,28,30,0.95)',
|
||||
highlight: '#1a3a5c',
|
||||
} as const;
|
||||
|
||||
const LIGHT = {
|
||||
background: '#f2f2f7',
|
||||
card: '#ffffff',
|
||||
text: '#1c1c1e',
|
||||
subtext: '#8e8e93',
|
||||
accent: '#B23CFF',
|
||||
border: '#e5e5ea',
|
||||
error: '#FF3B30',
|
||||
success: '#34C759',
|
||||
warning: '#FF9500',
|
||||
purple: '#8B5CF6',
|
||||
delete: '#FF3B30',
|
||||
overlay: 'rgba(255,255,255,0.95)',
|
||||
highlight: '#e8f4fd',
|
||||
} as const;
|
||||
|
||||
/** Color token type — every theme variant shares the same keys. */
|
||||
export type AppColors = Record<keyof typeof DARK, string>;
|
||||
|
||||
/**
|
||||
* Returns the full color palette (dark or light) based on the current theme.
|
||||
* Derives from {@link useTheme} so it stays in sync with user preferences.
|
||||
*/
|
||||
export function useColors(): AppColors {
|
||||
const { dark } = useTheme();
|
||||
return dark ? DARK : LIGHT;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { Journey } from '@timetoleave/core';
|
||||
import { loadNotificationSettings } from '../store/eventStore';
|
||||
|
||||
interface DepartureTimeResult {
|
||||
departureTime: Date | null;
|
||||
@@ -10,7 +9,20 @@ interface DepartureTimeResult {
|
||||
|
||||
/**
|
||||
* Calculate departure time based on selected transport mode.
|
||||
*
|
||||
* For train mode, picks the latest-departing journey that still arrives on time
|
||||
* (factoring in the final walking leg). For bike mode, simply subtracts the
|
||||
* cycling duration from the target arrival time.
|
||||
*
|
||||
* Mirrors the web app's useDepartureTime hook.
|
||||
*
|
||||
* @param eventTime - The scheduled event start time.
|
||||
* @param journeys - Available train journeys (may be null if not loaded yet).
|
||||
* @param bikeDurationSeconds - Cycling duration in seconds, or null if unavailable.
|
||||
* @param activeMode - The currently selected transport mode.
|
||||
* @param arrivalBufferMinutes - Minutes to arrive before the event starts.
|
||||
* @param trainWalkDurationSeconds - Walking time from destination station to event (seconds).
|
||||
* @param originWalkDurationSeconds - Walking time from start point to origin station (seconds).
|
||||
*/
|
||||
export function useDepartureTime(
|
||||
eventTime: Date,
|
||||
@@ -18,6 +30,8 @@ export function useDepartureTime(
|
||||
bikeDurationSeconds: number | null,
|
||||
activeMode: 'train' | 'bike' | null,
|
||||
arrivalBufferMinutes: number,
|
||||
trainWalkDurationSeconds = 0,
|
||||
originWalkDurationSeconds = 0,
|
||||
): DepartureTimeResult {
|
||||
return useMemo(() => {
|
||||
// Calculate target arrival time (event time minus buffer)
|
||||
@@ -33,8 +47,9 @@ export function useDepartureTime(
|
||||
|
||||
if (activeMode === 'train' && validJourneys.length > 0) {
|
||||
// Find journeys that arrive by target time
|
||||
const walkDurationMs = trainWalkDurationSeconds * 1000;
|
||||
const onTimeJourneys = validJourneys.filter(
|
||||
(journey) => journey.rA.getTime() <= targetArrivalTime.getTime(),
|
||||
(journey) => journey.rA.getTime() + walkDurationMs <= targetArrivalTime.getTime(),
|
||||
);
|
||||
|
||||
if (onTimeJourneys.length > 0) {
|
||||
@@ -43,8 +58,8 @@ export function useDepartureTime(
|
||||
current.rD.getTime() > latest.rD.getTime() ? current : latest,
|
||||
);
|
||||
|
||||
departureTime = new Date(bestJourney.rD);
|
||||
arrivalTime = new Date(bestJourney.rA);
|
||||
departureTime = new Date(bestJourney.rD.getTime() - originWalkDurationSeconds * 1000);
|
||||
arrivalTime = new Date(bestJourney.rA.getTime() + walkDurationMs);
|
||||
mode = 'train';
|
||||
}
|
||||
}
|
||||
@@ -60,5 +75,13 @@ export function useDepartureTime(
|
||||
}
|
||||
|
||||
return { departureTime, arrivalTime, mode };
|
||||
}, [eventTime, journeys, bikeDurationSeconds, activeMode, arrivalBufferMinutes]);
|
||||
}, [
|
||||
eventTime,
|
||||
journeys,
|
||||
bikeDurationSeconds,
|
||||
activeMode,
|
||||
arrivalBufferMinutes,
|
||||
trainWalkDurationSeconds,
|
||||
originWalkDurationSeconds,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,30 @@ import { useState, useEffect } from 'react';
|
||||
import type { Station } from '@timetoleave/core';
|
||||
import { api } from '../services/api';
|
||||
|
||||
/**
|
||||
* Geocodes a destination string to the nearest HAFAS station.
|
||||
*
|
||||
* Two-step process: first geocodes the address to lat/lng via Nominatim,
|
||||
* then sends those coordinates to HAFAS LocMatch to find the closest station.
|
||||
* Debounces lookups by 400 ms to avoid excessive API calls while typing.
|
||||
*/
|
||||
/** Intermediate shape returned by HAFAS LocMatch before we pick the best station. */
|
||||
interface HafasLocation {
|
||||
type: string;
|
||||
name: string;
|
||||
extId: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
crd?: {
|
||||
x?: number;
|
||||
y?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** HAFAS can return coordinates in either raw degrees or micro-degrees (×1e6). */
|
||||
function normalizeHafasCoordinate(value: number | undefined): number | undefined {
|
||||
if (value == null) return undefined;
|
||||
return Math.abs(value) > 1000 ? value / 1e6 : value;
|
||||
}
|
||||
|
||||
export function useDestinationStation(destination: string | undefined) {
|
||||
@@ -63,12 +81,16 @@ export function useDestinationStation(destination: string | undefined) {
|
||||
],
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = await api.hafasRequest<any>(body);
|
||||
const data = await api.hafasRequest<{ svcResL?: Array<{ res?: { match?: { locL?: HafasLocation[] } } }> }>(body);
|
||||
const locL: HafasLocation[] = data?.svcResL?.[0]?.res?.match?.locL ?? [];
|
||||
const stations = locL
|
||||
.filter((l) => l.type === 'S')
|
||||
.map((l) => ({ name: l.name, extId: l.extId, lat: l.lat, lng: l.lon }));
|
||||
.map((l) => ({
|
||||
name: l.name,
|
||||
extId: l.extId,
|
||||
lat: normalizeHafasCoordinate(l.lat ?? l.crd?.y),
|
||||
lng: normalizeHafasCoordinate(l.lon ?? l.crd?.x),
|
||||
}));
|
||||
|
||||
if (!isMounted) return;
|
||||
setStation(stations[0] ?? null);
|
||||
|
||||
@@ -4,6 +4,8 @@ import { api } from '../services/api';
|
||||
|
||||
/**
|
||||
* Geocode a destination name to coordinates.
|
||||
* Debounces API calls by 400 ms. Automatically resets to null when the
|
||||
* destination is cleared or becomes empty.
|
||||
* Mirrors the web app's useGeocode hook.
|
||||
*/
|
||||
export function useGeocode(destination: string | undefined) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Station } from '@timetoleave/core';
|
||||
import { api } from '../services/api';
|
||||
import { useWalkRoute } from './useWalkRoute';
|
||||
|
||||
/**
|
||||
* Resolve the walking leg from the saved origin point to the departure station.
|
||||
*
|
||||
* `origin.lat/lng` may represent the user's real start point while `origin.extId`
|
||||
* identifies the station used for train search. When coordinates are missing or
|
||||
* station resolution fails, callers can safely fall back to zero duration.
|
||||
*/
|
||||
export function useOriginStationWalk(origin: Station | null) {
|
||||
const [station, setStation] = useState<Station | null>(null);
|
||||
const [lookupError, setLookupError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const resolveStation = async () => {
|
||||
setStation(null);
|
||||
setLookupError(null);
|
||||
|
||||
if (origin?.lat == null || origin.lng == null) return;
|
||||
|
||||
try {
|
||||
const selectedStation = await api.findStationByExtId(origin.extId);
|
||||
if (!isMounted) return;
|
||||
setStation(selectedStation);
|
||||
} catch (err) {
|
||||
if (!isMounted) return;
|
||||
setLookupError(err instanceof Error ? err.message : 'Origin station lookup failed');
|
||||
}
|
||||
};
|
||||
|
||||
resolveStation();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [origin?.lat, origin?.lng]);
|
||||
|
||||
const walk = useWalkRoute(origin?.lat, origin?.lng, station?.lat, station?.lng);
|
||||
|
||||
return {
|
||||
station,
|
||||
walkRoute: walk.walkRoute,
|
||||
loading: walk.loading,
|
||||
error: lookupError ?? walk.error,
|
||||
};
|
||||
}
|
||||
@@ -1,18 +1,25 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
/** Theme storage key in AsyncStorage. */
|
||||
const THEME_KEY = '@timetoleave_theme';
|
||||
|
||||
type Theme = 'dark' | 'light';
|
||||
|
||||
/** Returns the default theme. Mobile defaults to dark to match the web app. */
|
||||
function getDefaultTheme(): Theme {
|
||||
// React Native doesn't have window.matchMedia, but we can use a simple default
|
||||
// In practice, we'd use useColorScheme from react-native for system preference
|
||||
return 'light';
|
||||
// The web app uses a dark-first theme, so we match that default
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme management for the mobile app.
|
||||
*
|
||||
* Persists the user's choice in AsyncStorage and initializes from storage
|
||||
* on mount (guarded by a ref so hot-reload doesn't re-read). Returns a
|
||||
* `dark` boolean and a `toggle` callback for switching themes.
|
||||
*
|
||||
* Mirrors the web app's useTheme hook.
|
||||
*/
|
||||
export function useTheme() {
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { WalkRoute } from '@timetoleave/core';
|
||||
import { api } from '../services/api';
|
||||
|
||||
/**
|
||||
* Fetch walk route between two points.
|
||||
* Fetch walk route between two points using the OSRM walking router.
|
||||
* Skips the request if any coordinate is missing, resetting state to null.
|
||||
* Mirrors the web app's useWalkRoute hook.
|
||||
*/
|
||||
export function useWalkRoute(
|
||||
@@ -21,6 +22,9 @@ export function useWalkRoute(
|
||||
|
||||
const fetchRoute = async () => {
|
||||
if (fromLat == null || fromLng == null || toLat == null || toLng == null) {
|
||||
setWalkRoute(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import type { WienerLinienStop, WienerLinienDeparture } from '@timetoleave/core';
|
||||
import { api } from '../services/api';
|
||||
|
||||
interface DepartureRow {
|
||||
/** Flattened departure row shown in the NearbyStops UI. */
|
||||
export interface DepartureRow {
|
||||
stopId: string;
|
||||
lineName: string;
|
||||
direction: string;
|
||||
@@ -12,6 +13,7 @@ interface DepartureRow {
|
||||
const DEBOUNCE_MS = 400;
|
||||
const REFRESH_INTERVAL_MS = 60_000;
|
||||
|
||||
/** Convert a raw WienerLinien departure to the simplified DepartureRow shape. */
|
||||
function transformDeparture(dep: WienerLinienDeparture): DepartureRow {
|
||||
const minutes = Math.max(0, Math.round((dep.departureTime - Date.now()) / 60_000));
|
||||
return {
|
||||
@@ -23,8 +25,14 @@ function transformDeparture(dep: WienerLinienDeparture): DepartureRow {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch nearby WienerLinien stops and their departures.
|
||||
* Mirrors the web app's useWienerLinien hook, adapted for mobile API client.
|
||||
* Fetches nearby WienerLinien stops around given coordinates and their live
|
||||
* departures. Debounces initial lookups (400 ms) and refreshes departures
|
||||
* every 60 seconds. Uses a cancellation ref to prevent stale overwrites when
|
||||
* coordinates change mid-request.
|
||||
*
|
||||
* @param lat - Latitude of the point of interest.
|
||||
* @param lng - Longitude of the point of interest.
|
||||
* @param radius - Search radius in meters (defaults to 500).
|
||||
*/
|
||||
export function useWienerLinien(
|
||||
lat: number | undefined,
|
||||
@@ -36,38 +44,42 @@ export function useWienerLinien(
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Stopped by the cleanup effect when coordinates change, so in-flight
|
||||
// monitor requests don't overwrite stale stop lists.
|
||||
const stopIdsRef = useRef<string[]>([]);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
// Effect for fetching stops and initial departures
|
||||
/** Fetch live departure data for a batch of stop IDs. Silently ignores errors. */
|
||||
const fetchMonitor = useCallback(async (stopIds: string[]): Promise<void> => {
|
||||
if (stopIds.length === 0 || cancelledRef.current) return;
|
||||
try {
|
||||
const rawDepartures = await api.monitorStops(stopIds);
|
||||
if (!cancelledRef.current) {
|
||||
setDepartures(rawDepartures.map(transformDeparture));
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore monitor errors — stops are still shown
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
cancelledRef.current = false;
|
||||
|
||||
const resetState = () => {
|
||||
if (lat === undefined || lng === undefined) {
|
||||
setStops([]);
|
||||
setDepartures([]);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
if (lat === undefined || lng === undefined) {
|
||||
resetState();
|
||||
return;
|
||||
}
|
||||
|
||||
const debounceTimer = setTimeout(async () => {
|
||||
if (cancelledRef.current) return;
|
||||
|
||||
abortRef.current?.abort();
|
||||
const abortController = new AbortController();
|
||||
abortRef.current = abortController;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Fetch nearby stops
|
||||
const stopsList = await api.findNearbyStops(lat, lng, radius ?? 500);
|
||||
|
||||
if (cancelledRef.current) return;
|
||||
@@ -78,20 +90,10 @@ export function useWienerLinien(
|
||||
const ids = stopsList.map((s) => s.id);
|
||||
stopIdsRef.current = ids;
|
||||
|
||||
// Chain monitor fetch for departures
|
||||
if (ids.length > 0) {
|
||||
try {
|
||||
// Fetch departures for each stop - note: mobile API client doesn't have
|
||||
// a direct monitor endpoint, so we skip this for now
|
||||
// The web app uses an internal API route for this
|
||||
} catch {
|
||||
// Silently ignore departure fetch errors
|
||||
}
|
||||
}
|
||||
await fetchMonitor(ids);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||
if (cancelledRef.current) return;
|
||||
setError(err instanceof Error ? err.message : 'An unexpected error occurred');
|
||||
setError(err instanceof Error ? err.message : 'Stops could not be loaded');
|
||||
setLoading(false);
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
@@ -99,25 +101,22 @@ export function useWienerLinien(
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
clearTimeout(debounceTimer);
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
};
|
||||
}, [lat, lng, radius]);
|
||||
}, [lat, lng, radius, fetchMonitor]);
|
||||
|
||||
// Effect for periodic departures refresh
|
||||
// Periodic departures refresh
|
||||
useEffect(() => {
|
||||
if (stops.length === 0) return;
|
||||
|
||||
const intervalId = setInterval(async () => {
|
||||
const currentIds = stopIdsRef.current;
|
||||
if (currentIds.length === 0) return;
|
||||
|
||||
// Refresh logic would go here if we had the monitor API
|
||||
// For now, this is a placeholder for future implementation
|
||||
const intervalId = setInterval(() => {
|
||||
const ids = stopIdsRef.current;
|
||||
if (ids.length > 0) {
|
||||
fetchMonitor(ids);
|
||||
}
|
||||
}, REFRESH_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [stops.length]);
|
||||
}, [stops.length, fetchMonitor]);
|
||||
|
||||
return { stops, departures, loading, error };
|
||||
}
|
||||
|
||||
@@ -1,36 +1,148 @@
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome';
|
||||
import { faBars } from '@fortawesome/free-solid-svg-icons/faBars';
|
||||
import { Image, Modal, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useState } from 'react';
|
||||
import { EventListScreen } from '../screens/EventListScreen';
|
||||
import { EventDetailScreen } from '../screens/EventDetailScreen';
|
||||
import { AddEventScreen } from '../screens/AddEventScreen';
|
||||
import { SettingsScreen } from '../screens/SettingsScreen';
|
||||
import { CalendarImportScreen } from '../screens/CalendarImportScreen';
|
||||
import type { RootStack } from '../types/navigation';
|
||||
import navLogo from '../../assets/nav-logo.png';
|
||||
|
||||
// ── Root Stack ──
|
||||
|
||||
const Root = createNativeStackNavigator<RootStack>();
|
||||
const byPrefixAndName = { fas: { bars: faBars } };
|
||||
|
||||
type HeaderMenuProps = {
|
||||
navigation: {
|
||||
navigate: (_screen: 'CalendarImport' | 'Settings') => void;
|
||||
};
|
||||
};
|
||||
|
||||
function HeaderMenu({ navigation }: HeaderMenuProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const navigateTo = (screen: 'CalendarImport' | 'Settings') => {
|
||||
setIsOpen(false);
|
||||
navigation.navigate(screen);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Pressable
|
||||
accessibilityLabel="Open menu"
|
||||
accessibilityRole="button"
|
||||
hitSlop={12}
|
||||
onPress={() => setIsOpen(true)}
|
||||
style={({ pressed }) => [styles.menuButton, pressed && styles.menuButtonPressed]}
|
||||
>
|
||||
<FontAwesomeIcon icon={byPrefixAndName.fas['bars']} color="#F4F1EA" size={22} />
|
||||
</Pressable>
|
||||
|
||||
<Modal animationType="fade" transparent visible={isOpen} onRequestClose={() => setIsOpen(false)}>
|
||||
<Pressable style={styles.menuOverlay} onPress={() => setIsOpen(false)}>
|
||||
<View style={styles.menuPanel}>
|
||||
<Pressable
|
||||
accessibilityRole="menuitem"
|
||||
onPress={() => navigateTo('CalendarImport')}
|
||||
style={({ pressed }) => [styles.menuItem, pressed && styles.menuItemPressed]}
|
||||
>
|
||||
<Text style={styles.menuItemText}>Calendar</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="menuitem"
|
||||
onPress={() => navigateTo('Settings')}
|
||||
style={({ pressed }) => [styles.menuItem, pressed && styles.menuItemPressed]}
|
||||
>
|
||||
<Text style={styles.menuItemText}>Settings</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Root native stack navigator for the app.
|
||||
* Wraps all screens in SafeAreaProvider/SafeAreaView with a dark header bar.
|
||||
*/
|
||||
export default function AppNavigator() {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: '#f2f2f7' }}>
|
||||
<StatusBar style="auto" />
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: '#090816' }}>
|
||||
<StatusBar style="light" />
|
||||
<NavigationContainer>
|
||||
<Root.Navigator
|
||||
initialRouteName="EventList"
|
||||
screenOptions={{ headerStyle: { backgroundColor: '#007AFF' }, headerTintColor: '#fff' }}
|
||||
screenOptions={({ navigation }) => ({
|
||||
headerStyle: { backgroundColor: '#17112A' },
|
||||
headerTintColor: '#F4F1EA',
|
||||
headerRight: () => <HeaderMenu navigation={navigation} />,
|
||||
headerTitle: () => (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<Image source={navLogo} style={{ width: 28, height: 28, borderRadius: 6 }} resizeMode="contain" />
|
||||
<Text style={{ color: '#F4F1EA', fontSize: 18, fontWeight: '600' }}>Time</Text>
|
||||
<Text style={{ color: '#ea1579', fontSize: 18, fontWeight: '600' }}>To </Text>
|
||||
<Text style={{ color: '#F4F1EA', fontSize: 18, fontWeight: '600' }}>Leave</Text>
|
||||
|
||||
</View>
|
||||
),
|
||||
})}
|
||||
>
|
||||
<Root.Screen name="EventList" component={EventListScreen} options={{ title: 'Time To Leave' }} />
|
||||
<Root.Screen name="AddEvent" component={AddEventScreen} options={{ title: 'Add Event' }} />
|
||||
<Root.Screen name="EventDetail" component={EventDetailScreen} options={{ title: 'Event Details' }} />
|
||||
<Root.Screen name="Settings" component={SettingsScreen} options={{ title: 'Settings' }} />
|
||||
<Root.Screen name="CalendarImport" component={CalendarImportScreen} options={{ title: 'Import Calendar' }} />
|
||||
<Root.Screen name="EventList" component={EventListScreen} />
|
||||
<Root.Screen name="AddEvent" component={AddEventScreen} />
|
||||
<Root.Screen name="EventDetail" component={EventDetailScreen} />
|
||||
<Root.Screen name="Settings" component={SettingsScreen} />
|
||||
<Root.Screen name="CalendarImport" component={CalendarImportScreen} />
|
||||
</Root.Navigator>
|
||||
</NavigationContainer>
|
||||
</SafeAreaView>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
menuButton: {
|
||||
alignItems: 'center',
|
||||
height: 40,
|
||||
justifyContent: 'center',
|
||||
width: 40,
|
||||
},
|
||||
menuButtonPressed: {
|
||||
opacity: 0.65,
|
||||
},
|
||||
menuOverlay: {
|
||||
alignItems: 'flex-end',
|
||||
backgroundColor: 'rgba(9, 8, 22, 0.45)',
|
||||
flex: 1,
|
||||
paddingRight: 12,
|
||||
paddingTop: 72,
|
||||
},
|
||||
menuPanel: {
|
||||
backgroundColor: '#17112A',
|
||||
borderColor: '#3B3157',
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
minWidth: 168,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
menuItem: {
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 14,
|
||||
},
|
||||
menuItemPressed: {
|
||||
backgroundColor: '#2A2140',
|
||||
},
|
||||
menuItemText: {
|
||||
color: '#F4F1EA',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
/**
|
||||
* Polyfills for newer JavaScript features that React Native's Hermes engine
|
||||
* doesn't provide out of the box.
|
||||
*
|
||||
* - `String.prototype.toWellFormed` / `isWellFormed` — handles lone surrogates
|
||||
* by replacing them with the Unicode replacement character (U+FFFD).
|
||||
* - `ArrayBuffer.prototype.resizable` — required by newer Intl APIs.
|
||||
* - `SharedArrayBuffer` — stubbed so that libraries which check for its
|
||||
* presence (e.g. the Intl locale data) don't crash at runtime.
|
||||
*/
|
||||
|
||||
const globalScope = globalThis as Record<string, unknown>;
|
||||
const stringPrototype = String.prototype as typeof String.prototype & {
|
||||
isWellFormed?: () => boolean;
|
||||
@@ -6,6 +17,11 @@ const stringPrototype = String.prototype as typeof String.prototype & {
|
||||
|
||||
const arrayBufferByteLength = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'byteLength')?.get;
|
||||
|
||||
/**
|
||||
* Replace lone surrogates (U+D800…U+DBFF without a partner, or U+DC00…U+DFFF
|
||||
* without a lead) with the Unicode replacement character U+FFFD. Valid
|
||||
* surrogate pairs are kept as-is. Used by both `toWellFormed` and `isWellFormed`.
|
||||
*/
|
||||
function toWellFormedString(value: string): string {
|
||||
let result = '';
|
||||
|
||||
|
||||
@@ -6,43 +6,35 @@ import {
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome';
|
||||
import { faCheck } from '@fortawesome/free-solid-svg-icons';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import type { RouteProp } from '@react-navigation/native';
|
||||
import { loadEvents, addEvent, updateEvent } from '../store/eventStore';
|
||||
import type { Event as CalendarEvent } from '@timetoleave/core';
|
||||
import type { RootStack } from '../types/navigation';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
import { useColors } from '../hooks/useColors';
|
||||
|
||||
type ScreenProps = {
|
||||
navigation: NativeStackNavigationProp<RootStack, 'AddEvent'>;
|
||||
route: RouteProp<RootStack, 'AddEvent'>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Form screen for creating or editing an event.
|
||||
*
|
||||
* When navigated with `editEventId`, loads the existing event and populates
|
||||
* the form fields. Validates that the event time is in the future before saving.
|
||||
* Shows a transient success overlay for 1.5 s before popping back.
|
||||
*/
|
||||
export function AddEventScreen({ navigation, route }: ScreenProps) {
|
||||
const { dark } = useTheme();
|
||||
const colors = useColors();
|
||||
const [title, setTitle] = useState('');
|
||||
const [destination, setDestination] = useState('');
|
||||
const [dateStr, setDateStr] = useState('');
|
||||
const [timeStr, setTimeStr] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const colors = dark ? {
|
||||
background: '#1c1c1e',
|
||||
card: '#2c2c2e',
|
||||
text: '#f2f2f2',
|
||||
subtext: '#aeaeb2',
|
||||
accent: '#0a84ff',
|
||||
border: '#38383a',
|
||||
error: '#ff453a',
|
||||
} : {
|
||||
background: '#f2f2f7',
|
||||
card: '#ffffff',
|
||||
text: '#1c1c1e',
|
||||
subtext: '#8e8e93',
|
||||
accent: '#007AFF',
|
||||
border: '#e5e5ea',
|
||||
error: '#FF3B30',
|
||||
};
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
// If editing an existing event, populate the form
|
||||
useEffect(() => {
|
||||
@@ -62,12 +54,12 @@ export function AddEventScreen({ navigation, route }: ScreenProps) {
|
||||
}, [route.params?.editEventId]);
|
||||
|
||||
const validate = (): boolean => {
|
||||
if (!title.trim()) { setError('Titel erforderlich'); return false; }
|
||||
if (!destination.trim()) { setError('Ziel erforderlich'); return false; }
|
||||
if (!dateStr || !timeStr) { setError('Datum und Zeit erforderlich'); return false; }
|
||||
if (!title.trim()) { setError('Title required'); return false; }
|
||||
if (!destination.trim()) { setError('Destination required'); return false; }
|
||||
if (!dateStr || !timeStr) { setError('Date and time required'); return false; }
|
||||
const eventTime = new Date(`${dateStr}T${timeStr}`);
|
||||
if (isNaN(eventTime.getTime())) { setError('Ungültiges Datum'); return false; }
|
||||
if (eventTime <= new Date()) { setError('Datum muss in der Zukunft liegen'); return false; }
|
||||
if (isNaN(eventTime.getTime())) { setError('Invalid date'); return false; }
|
||||
if (eventTime <= new Date()) { setError('Date must be in the future'); return false; }
|
||||
setError('');
|
||||
return true;
|
||||
};
|
||||
@@ -97,46 +89,49 @@ export function AddEventScreen({ navigation, route }: ScreenProps) {
|
||||
await addEvent(event);
|
||||
}
|
||||
|
||||
navigation.goBack();
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
navigation.goBack();
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={[styles.container, { backgroundColor: colors.background, position: 'relative' }]}>
|
||||
<View style={styles.form}>
|
||||
<Text style={[styles.label, { color: colors.text }]}>Titel</Text>
|
||||
<Text style={[styles.label, { color: colors.text }]}>Title</Text>
|
||||
<TextInput
|
||||
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
|
||||
placeholder="z.B. Team Meeting"
|
||||
placeholder="e.g. Team Meeting"
|
||||
placeholderTextColor={colors.subtext}
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
autoCapitalize="words"
|
||||
/>
|
||||
|
||||
<Text style={[styles.label, { color: colors.text }]}>Ziel</Text>
|
||||
<Text style={[styles.label, { color: colors.text }]}>Destination</Text>
|
||||
<TextInput
|
||||
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
|
||||
placeholder="z.B. Wien, Donau-City"
|
||||
placeholder="e.g. Technikum Wien"
|
||||
placeholderTextColor={colors.subtext}
|
||||
value={destination}
|
||||
onChangeText={setDestination}
|
||||
autoCapitalize="words"
|
||||
/>
|
||||
|
||||
<Text style={[styles.label, { color: colors.text }]}>Datum</Text>
|
||||
<Text style={[styles.label, { color: colors.text }]}>Date</Text>
|
||||
<TextInput
|
||||
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
|
||||
placeholder="JJJJ-MM-TT"
|
||||
placeholder="YYYY-MM-DD"
|
||||
placeholderTextColor={colors.subtext}
|
||||
value={dateStr}
|
||||
onChangeText={setDateStr}
|
||||
keyboardType="numbers-and-punctuation"
|
||||
/>
|
||||
|
||||
<Text style={[styles.label, { color: colors.text }]}>Zeit</Text>
|
||||
<Text style={[styles.label, { color: colors.text }]}>Time</Text>
|
||||
<TextInput
|
||||
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
|
||||
placeholder="SS:MM"
|
||||
placeholder="HH:MM"
|
||||
placeholderTextColor={colors.subtext}
|
||||
value={timeStr}
|
||||
onChangeText={setTimeStr}
|
||||
@@ -146,16 +141,30 @@ export function AddEventScreen({ navigation, route }: ScreenProps) {
|
||||
{error ? <Text style={[styles.errorText, { color: colors.error }]}>{error}</Text> : null}
|
||||
|
||||
<TouchableOpacity style={styles.saveBtn} onPress={handleSave}>
|
||||
<Text style={styles.saveBtnText}>{route.params?.editEventId ? 'Aktualisieren' : 'Speichern'}</Text>
|
||||
<Text style={styles.saveBtnText}>{route.params?.editEventId ? 'Update' : 'Save'}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.saveBtn, styles.cancelBtn, { backgroundColor: colors.border }]}
|
||||
onPress={() => navigation.goBack()}
|
||||
>
|
||||
<Text style={[styles.cancelText, { color: colors.text }]}>Abbrechen</Text>
|
||||
<Text style={[styles.cancelText, { color: colors.text }]}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{success && (
|
||||
<View style={[styles.successOverlay, { backgroundColor: colors.overlay }]}>
|
||||
<View style={styles.successContent}>
|
||||
<View style={styles.successCircle}>
|
||||
<FontAwesomeIcon icon={faCheck} size={24} color="#fff" />
|
||||
</View>
|
||||
<Text style={[styles.successTitle, { color: colors.text }]}>Success!</Text>
|
||||
<Text style={[styles.successSubtitle, { color: colors.subtext }]}>
|
||||
{route.params?.editEventId ? 'Event updated' : 'Event added'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -174,7 +183,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
errorText: { fontSize: 14, marginBottom: 8 },
|
||||
saveBtn: {
|
||||
backgroundColor: '#007AFF',
|
||||
backgroundColor: '#8B5CF6',
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
@@ -183,4 +192,26 @@ const styles = StyleSheet.create({
|
||||
saveBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
|
||||
cancelBtn: { marginTop: 12 },
|
||||
cancelText: { fontSize: 16, fontWeight: '600' },
|
||||
successOverlay: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: 20,
|
||||
paddingBottom: 40,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#34C759',
|
||||
alignItems: 'center',
|
||||
},
|
||||
successContent: { alignItems: 'center', gap: 8 },
|
||||
successCircle: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: '#34C759',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
successTitle: { fontSize: 18, fontWeight: '600' },
|
||||
successSubtitle: { fontSize: 14 },
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
@@ -8,52 +8,160 @@ import {
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome';
|
||||
import { faArrowLeft, faCalendarDays, faCheck } from '@fortawesome/free-solid-svg-icons';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import type { RouteProp } from '@react-navigation/native';
|
||||
import { api } from '../services/api';
|
||||
import { fetchNativeEvents } from '../services/calendar';
|
||||
import { addEvent, loadEvents } from '../store/eventStore';
|
||||
import type { Event as CalendarEvent } from '@timetoleave/core';
|
||||
import { accountTypeIcon, fetchNativeEvents, getSelectableCalendars, groupCalendarsByType } from '../services/calendar';
|
||||
import { addEvent, getSelectedCalendarIds, hasCalendarSelection, loadEvents, saveSelectedCalendarIds } from '../store/eventStore';
|
||||
import type { Event as CalendarEvent, CalendarAccountType, SelectableCalendar } from '@timetoleave/core';
|
||||
import type { RootStack } from '../types/navigation';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
import { useColors } from '../hooks/useColors';
|
||||
|
||||
type ScreenProps = {
|
||||
navigation: NativeStackNavigationProp<RootStack, 'CalendarImport'>;
|
||||
route: RouteProp<RootStack, 'CalendarImport'>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Display order for calendar source type groups.
|
||||
*/
|
||||
const GROUP_ORDER: CalendarAccountType[] = [
|
||||
'caldav',
|
||||
'mobileme',
|
||||
'google',
|
||||
'exchange',
|
||||
'subscriptions',
|
||||
'local',
|
||||
'carddav',
|
||||
'activesync',
|
||||
'other',
|
||||
'none',
|
||||
];
|
||||
|
||||
/** Badge color for each account type. */
|
||||
const BADGE_COLORS: Record<string, string> = {
|
||||
caldav: '#34C759',
|
||||
mobileme: '#FF9500',
|
||||
google: '#4285F4',
|
||||
exchange: '#0078D4',
|
||||
subscriptions: '#AF52DE',
|
||||
local: '#8E8E93',
|
||||
carddav: '#5AC8FA',
|
||||
activesync: '#FF2D55',
|
||||
other: '#8E8E93',
|
||||
none: '#8E8E93',
|
||||
};
|
||||
|
||||
/** Calendar checkbox row component. */
|
||||
function CalendarCheckbox({
|
||||
calendar,
|
||||
selected,
|
||||
onToggle,
|
||||
colors,
|
||||
}: {
|
||||
calendar: SelectableCalendar;
|
||||
selected: boolean;
|
||||
onToggle: (_id: string) => void;
|
||||
colors: ReturnType<typeof useColors>;
|
||||
}) {
|
||||
const badgeColor = BADGE_COLORS[calendar.accountType] ?? BADGE_COLORS.other;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.calendarRow, { borderColor: selected ? colors.accent : colors.border }]}
|
||||
onPress={() => onToggle(calendar.id)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${calendar.name} – ${calendar.sourceInfo.label}`}
|
||||
>
|
||||
<View style={[styles.checkBox, selected && styles.checkBoxSelected]}>
|
||||
{selected && <FontAwesomeIcon icon={faCheck} size={12} color="#fff" />}
|
||||
</View>
|
||||
<View style={styles.calendarInfo}>
|
||||
<View style={styles.calendarNameRow}>
|
||||
<FontAwesomeIcon icon={accountTypeIcon(calendar.accountType)} size={14} color={colors.text} />
|
||||
<Text style={[styles.calendarName, { color: colors.text }]}>{calendar.name}</Text>
|
||||
</View>
|
||||
<View style={styles.badgeRow}>
|
||||
<View style={[styles.badge, { backgroundColor: badgeColor }]}>
|
||||
<Text style={styles.badgeText}>{calendar.sourceInfo.badge}</Text>
|
||||
</View>
|
||||
<Text style={[styles.accountTypeText, { color: colors.subtext }]}>
|
||||
{calendar.sourceInfo.label}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Screen for importing events from either an ICS calendar URL or the device's
|
||||
* native calendar. Includes calendar selection so the user can choose which
|
||||
* calendars to sync. Deduplicates against already-imported events by ID.
|
||||
*/
|
||||
export function CalendarImportScreen({ navigation }: ScreenProps) {
|
||||
const { dark } = useTheme();
|
||||
const colors = useColors();
|
||||
const [url, setUrl] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [count, setCount] = useState<number | null>(null);
|
||||
|
||||
const colors = dark ? {
|
||||
background: '#1c1c1e',
|
||||
card: '#2c2c2e',
|
||||
text: '#f2f2f2',
|
||||
subtext: '#aeaeb2',
|
||||
accent: '#0a84ff',
|
||||
border: '#38383a',
|
||||
error: '#ff453a',
|
||||
success: '#30d158',
|
||||
purple: '#bf5af2',
|
||||
} : {
|
||||
background: '#f2f2f7',
|
||||
card: '#ffffff',
|
||||
text: '#1c1c1e',
|
||||
subtext: '#8e8e93',
|
||||
accent: '#007AFF',
|
||||
border: '#e5e5ea',
|
||||
error: '#FF3B30',
|
||||
success: '#34C759',
|
||||
purple: '#5856D6',
|
||||
};
|
||||
// Calendar selection state
|
||||
const [availableCalendars, setAvailableCalendars] = useState<SelectableCalendar[]>([]);
|
||||
const [selectedCalendarIds, setSelectedCalendarIds] = useState<Set<string>>(new Set());
|
||||
const [calendarsLoaded, setCalendarsLoaded] = useState(false);
|
||||
const [hasSelection, setHasSelection] = useState(false);
|
||||
const initialLoadRef = useRef(false);
|
||||
|
||||
// Load available calendars and persisted selection on mount
|
||||
useEffect(() => {
|
||||
if (initialLoadRef.current) return;
|
||||
initialLoadRef.current = true;
|
||||
|
||||
(async () => {
|
||||
const [calendars, persistedIds, hasSel] = await Promise.all([
|
||||
getSelectableCalendars(),
|
||||
getSelectedCalendarIds(),
|
||||
hasCalendarSelection(),
|
||||
]);
|
||||
setAvailableCalendars(calendars);
|
||||
setSelectedCalendarIds(new Set(persistedIds));
|
||||
setHasSelection(hasSel);
|
||||
setCalendarsLoaded(true);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const toggleCalendar = useCallback((id: string) => {
|
||||
setSelectedCalendarIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
setSelectedCalendarIds(new Set(availableCalendars.map((c) => c.id)));
|
||||
}, [availableCalendars]);
|
||||
|
||||
const deselectAll = useCallback(() => {
|
||||
setSelectedCalendarIds(new Set());
|
||||
}, []);
|
||||
|
||||
const saveSelection = useCallback(async () => {
|
||||
const ids = Array.from(selectedCalendarIds);
|
||||
await saveSelectedCalendarIds(ids);
|
||||
setHasSelection(ids.length > 0);
|
||||
}, [selectedCalendarIds]);
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!url.trim()) {
|
||||
setError('Bitte ICS-URL eingeben');
|
||||
setError('Please enter ICS URL');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -62,21 +170,28 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
|
||||
setCount(null);
|
||||
|
||||
try {
|
||||
const events = await api.fetchCalendar(url.trim());
|
||||
// Add imported events to local store
|
||||
const [events, existing] = await Promise.all([
|
||||
api.fetchCalendar(url.trim()),
|
||||
loadEvents(),
|
||||
]);
|
||||
const existingIds = new Set(existing.map((e) => e.id));
|
||||
let added = 0;
|
||||
for (const evt of events) {
|
||||
const localEvent: CalendarEvent = {
|
||||
id: evt.id,
|
||||
title: evt.title,
|
||||
destination: evt.destination,
|
||||
eventTime: new Date(evt.eventTime),
|
||||
source: `calendar:${url.trim().slice(0, 40)}`,
|
||||
};
|
||||
await addEvent(localEvent);
|
||||
if (!existingIds.has(evt.id)) {
|
||||
const localEvent: CalendarEvent = {
|
||||
id: evt.id,
|
||||
title: evt.title,
|
||||
destination: evt.destination,
|
||||
eventTime: new Date(evt.eventTime),
|
||||
source: `calendar:${url.trim().slice(0, 40)}`,
|
||||
};
|
||||
await addEvent(localEvent);
|
||||
added++;
|
||||
}
|
||||
}
|
||||
setCount(events.length);
|
||||
setCount(added);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Import fehlgeschlagen');
|
||||
setError(err instanceof Error ? err.message : 'Import failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -88,11 +203,19 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
|
||||
setCount(null);
|
||||
|
||||
try {
|
||||
// Save the current selection before syncing
|
||||
await saveSelection();
|
||||
|
||||
// Fetch events from the next 30 days
|
||||
const now = new Date();
|
||||
const thirtyDaysLater = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const nativeEvents = await fetchNativeEvents(now, thirtyDaysLater);
|
||||
// If a selection was made, sync only selected calendars
|
||||
const calendarIds = hasSelection
|
||||
? Array.from(selectedCalendarIds)
|
||||
: undefined;
|
||||
|
||||
const nativeEvents = await fetchNativeEvents(now, thirtyDaysLater, calendarIds);
|
||||
|
||||
// Load existing events to avoid duplicates
|
||||
const existing = await loadEvents();
|
||||
@@ -108,22 +231,26 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
|
||||
|
||||
setCount(added);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Sync fehlgeschlagen');
|
||||
setError(err instanceof Error ? err.message : 'Sync failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Group calendars by account type for display
|
||||
const grouped = groupCalendarsByType(availableCalendars);
|
||||
|
||||
return (
|
||||
<ScrollView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.content}>
|
||||
<Text style={[styles.heading, { color: colors.text }]}>Kalender-Import</Text>
|
||||
<Text style={[styles.heading, { color: colors.text }]}>Calendar Import</Text>
|
||||
<Text style={[styles.description, { color: colors.subtext }]}>
|
||||
Importiere Termine über eine ICS-URL oder sync mit dem Geräte-Kalender.
|
||||
Import events via an ICS URL or sync with the device calendar.
|
||||
</Text>
|
||||
|
||||
{/* ── ICS URL Import ── */}
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>ICS-URL Import</Text>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>ICS URL Import</Text>
|
||||
|
||||
<TextInput
|
||||
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
|
||||
@@ -143,15 +270,87 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.importBtnText}>ICS Importieren</Text>
|
||||
<Text style={styles.importBtnText}>Import ICS</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* ── Calendar Selection ── */}
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Geräte-Kalender Sync</Text>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Select calendars</Text>
|
||||
<View style={styles.selectionActions}>
|
||||
<TouchableOpacity onPress={selectAll} accessibilityLabel="Select all">
|
||||
<Text style={[styles.linkText, { color: colors.accent }]}>All</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.dividerText, { color: colors.subtext }]}> · </Text>
|
||||
<TouchableOpacity onPress={deselectAll} accessibilityLabel="Deselect all">
|
||||
<Text style={[styles.linkText, { color: colors.accent }]}>None</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.sectionDesc, { color: colors.subtext }]}>
|
||||
Hole Termine der nächsten 30 Tage aus den Kalendern auf deinem Gerät.
|
||||
Select the calendars to sync. If no selection is made, all calendars will be used.
|
||||
{'\n'}
|
||||
CalDAV sources (DAVx5, Apple Calendar, etc.) are detected automatically.
|
||||
</Text>
|
||||
|
||||
{!calendarsLoaded ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator color={colors.accent} />
|
||||
<Text style={[styles.loadingText, { color: colors.subtext }]}>Loading calendars…</Text>
|
||||
</View>
|
||||
) : availableCalendars.length === 0 ? (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={[styles.emptyText, { color: colors.subtext }]}>
|
||||
No calendars found on this device.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{/* Render groups in defined order, then any remaining types */}
|
||||
{GROUP_ORDER.map((type) => {
|
||||
const group = grouped.get(type);
|
||||
if (!group || group.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View key={type} style={styles.group}>
|
||||
<View style={styles.groupLabelRow}>
|
||||
<FontAwesomeIcon icon={accountTypeIcon(type)} size={11} color={colors.subtext} />
|
||||
<Text style={[styles.groupLabel, { color: colors.subtext }]}>
|
||||
{group[0].sourceInfo.label} ({group.length})
|
||||
</Text>
|
||||
</View>
|
||||
{group.map((cal) => (
|
||||
<CalendarCheckbox
|
||||
key={cal.id}
|
||||
calendar={cal}
|
||||
selected={selectedCalendarIds.has(cal.id)}
|
||||
onToggle={toggleCalendar}
|
||||
colors={colors}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
|
||||
{hasSelection && (
|
||||
<View style={[styles.selectionInfo, { backgroundColor: colors.highlight }]}>
|
||||
<Text style={[styles.selectionInfoText, { color: colors.text }]}>
|
||||
{selectedCalendarIds.size} of {availableCalendars.length} calendars selected
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* ── Device Calendar Sync ── */}
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Device Calendar Sync</Text>
|
||||
<Text style={[styles.sectionDesc, { color: colors.subtext }]}>
|
||||
Fetch events for the next 30 days from the calendars on your device.
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
@@ -159,7 +358,10 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
|
||||
onPress={handleSyncNative}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={styles.importBtnText}>📅 Kalender Sync</Text>
|
||||
<View style={styles.buttonContent}>
|
||||
<FontAwesomeIcon icon={faCalendarDays} size={15} color="#fff" />
|
||||
<Text style={styles.importBtnText}>Calendar Sync</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -171,9 +373,10 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
|
||||
|
||||
{count !== null && (
|
||||
<View style={[styles.successBanner, { backgroundColor: colors.success }]}>
|
||||
<Text style={styles.successText}>
|
||||
✓ {count} Termin(e) erfolgreich importiert!
|
||||
</Text>
|
||||
<View style={styles.bannerContent}>
|
||||
<FontAwesomeIcon icon={faCheck} size={13} color="#fff" />
|
||||
<Text style={styles.successText}>{count} event(s) successfully imported!</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -181,7 +384,10 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
|
||||
style={styles.backBtn}
|
||||
onPress={() => navigation.goBack()}
|
||||
>
|
||||
<Text style={[styles.backBtnText, { color: colors.accent }]}>← Zurück</Text>
|
||||
<View style={styles.backBtnContent}>
|
||||
<FontAwesomeIcon icon={faArrowLeft} size={13} color={colors.accent} />
|
||||
<Text style={[styles.backBtnText, { color: colors.accent }]}>Back</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
@@ -194,6 +400,12 @@ const styles = StyleSheet.create({
|
||||
heading: { fontSize: 22, fontWeight: '700', marginBottom: 4 },
|
||||
description: { fontSize: 14, marginBottom: 20, lineHeight: 20 },
|
||||
section: { marginBottom: 24 },
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
},
|
||||
sectionTitle: { fontSize: 16, fontWeight: '600', marginBottom: 8 },
|
||||
sectionDesc: { fontSize: 13, marginBottom: 12, lineHeight: 18 },
|
||||
input: {
|
||||
@@ -209,16 +421,128 @@ const styles = StyleSheet.create({
|
||||
successBanner: { borderRadius: 8, padding: 12, marginBottom: 12 },
|
||||
successText: { color: '#fff', fontSize: 14 },
|
||||
importBtn: {
|
||||
backgroundColor: '#007AFF',
|
||||
backgroundColor: '#8B5CF6',
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonContent: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
importBtnDisabled: { opacity: 0.6 },
|
||||
importBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
|
||||
bannerContent: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
backBtn: {
|
||||
paddingVertical: 10,
|
||||
alignItems: 'center',
|
||||
},
|
||||
backBtnContent: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||
backBtnText: { fontSize: 15 },
|
||||
// Calendar selection
|
||||
selectionActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
linkText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
dividerText: {
|
||||
fontSize: 13,
|
||||
marginHorizontal: 4,
|
||||
},
|
||||
group: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
groupLabelRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
marginBottom: 6,
|
||||
marginTop: 4,
|
||||
},
|
||||
groupLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
calendarRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1.5,
|
||||
marginBottom: 6,
|
||||
},
|
||||
checkBox: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 6,
|
||||
borderWidth: 2,
|
||||
borderColor: '#8E8E93',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
checkBoxSelected: {
|
||||
backgroundColor: '#8B5CF6',
|
||||
borderColor: '#8B5CF6',
|
||||
},
|
||||
calendarInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
calendarNameRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginBottom: 2,
|
||||
},
|
||||
calendarName: {
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
flex: 1,
|
||||
},
|
||||
badgeRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
badge: {
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 1,
|
||||
borderRadius: 4,
|
||||
marginRight: 6,
|
||||
},
|
||||
badgeText: {
|
||||
color: '#fff',
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
},
|
||||
accountTypeText: {
|
||||
fontSize: 11,
|
||||
},
|
||||
loadingContainer: {
|
||||
padding: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 8,
|
||||
fontSize: 13,
|
||||
},
|
||||
emptyContainer: {
|
||||
padding: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 13,
|
||||
textAlign: 'center',
|
||||
},
|
||||
selectionInfo: {
|
||||
padding: 10,
|
||||
borderRadius: 8,
|
||||
marginTop: 4,
|
||||
},
|
||||
selectionInfoText: {
|
||||
fontSize: 13,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,18 +7,32 @@ import {
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome';
|
||||
import { faArrowsRotate, faBicycle, faTrain, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import type { RouteProp } from '@react-navigation/native';
|
||||
import { loadEvents, loadOriginStation, loadNotificationSettings } from '../store/eventStore';
|
||||
import {
|
||||
getCachedJourneys,
|
||||
getCachedBikeRoute,
|
||||
getCachedWalkRoute,
|
||||
setCachedJourneys,
|
||||
setCachedBikeRoute,
|
||||
setCachedWalkRoute,
|
||||
} from '../store/apiCache';
|
||||
import { api } from '../services/api';
|
||||
import { formatDuration, formatDistance } from '@timetoleave/core';
|
||||
import type { Journey, BikeRoute, Station, Event as CalendarEvent, WalkRoute } from '@timetoleave/core';
|
||||
import { useDestinationStation } from '../hooks/useDestinationStation';
|
||||
import { useDepartureTime } from '../hooks/useDepartureTime';
|
||||
import { useGeocode } from '../hooks/useGeocode';
|
||||
import { useOriginStationWalk } from '../hooks/useOriginStationWalk';
|
||||
import { useWalkRoute } from '../hooks/useWalkRoute';
|
||||
import { useWienerLinien } from '../hooks/useWienerLinien';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
import { useColors } from '../hooks/useColors';
|
||||
import { EventHeader } from '../components/EventHeader';
|
||||
import { JourneyList } from '../components/JourneyList';
|
||||
import { BikeSection } from '../components/BikeSection';
|
||||
import { NearbyStops } from '../components/NearbyStops';
|
||||
import type { RootStack } from '../types/navigation';
|
||||
|
||||
type ScreenProps = {
|
||||
@@ -28,9 +42,22 @@ type ScreenProps = {
|
||||
|
||||
type TransportMode = 'train' | 'bike';
|
||||
|
||||
/**
|
||||
* Compute the target arrival time at the destination station.
|
||||
* Subtracts both the arrival buffer (time before event) and the walking
|
||||
* duration from station to event location.
|
||||
*/
|
||||
function stationArrivalTarget(
|
||||
eventTime: Date,
|
||||
arrivalBufferMinutes: number,
|
||||
walkDurationSeconds: number,
|
||||
) {
|
||||
return new Date(eventTime.getTime() - arrivalBufferMinutes * 60_000 - walkDurationSeconds * 1000);
|
||||
}
|
||||
|
||||
export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
||||
const { eventId } = route.params;
|
||||
const { dark } = useTheme();
|
||||
const colors = useColors();
|
||||
|
||||
const [event, setEvent] = useState<CalendarEvent | null>(null);
|
||||
const [journeys, setJourneys] = useState<Journey[]>([]);
|
||||
@@ -46,26 +73,29 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
||||
const [showBikeOption, setShowBikeOption] = useState(true);
|
||||
const [showWalkingOption, setShowWalkingOption] = useState(true);
|
||||
|
||||
// Resolve destination text to HAFAS station ID (CRITICAL FIX)
|
||||
const destStation = useDestinationStation(event?.destination);
|
||||
|
||||
// Geocode destination for bike/walk routes
|
||||
const destCoords = useGeocode(event?.destination);
|
||||
|
||||
// Fetch walk route from destination station to final address
|
||||
const walkHook = useWalkRoute(
|
||||
destStation.station?.lat,
|
||||
destStation.station?.lng,
|
||||
destCoords.coords?.lat,
|
||||
destCoords.coords?.lng,
|
||||
);
|
||||
|
||||
// Fetch nearby WienerLinien stops
|
||||
const originWalk = useOriginStationWalk(origin);
|
||||
const wienerLinien = useWienerLinien(destCoords.coords?.lat, destCoords.coords?.lng);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Preload cached data immediately so the UI isn't empty
|
||||
const cachedJourneys = await getCachedJourneys(eventId);
|
||||
const cachedBike = await getCachedBikeRoute(eventId);
|
||||
const cachedWalk = await getCachedWalkRoute(eventId);
|
||||
if (cachedJourneys) setJourneys(cachedJourneys);
|
||||
if (cachedBike) setBikeRoute(cachedBike);
|
||||
if (cachedWalk) setWalkRoute(cachedWalk);
|
||||
|
||||
try {
|
||||
const [events, originStation, settings] = await Promise.all([
|
||||
loadEvents(),
|
||||
@@ -78,58 +108,76 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
||||
setShowWalkingOption(settings.showWalkingOption);
|
||||
|
||||
const found = events.find((e) => e.id === eventId);
|
||||
if (!found) {
|
||||
setError('Termin nicht gefunden');
|
||||
return;
|
||||
}
|
||||
if (!found) { setError('Event not found'); return; }
|
||||
setEvent(found);
|
||||
|
||||
if (originStation) {
|
||||
// Use resolved destination station extId instead of raw text (CRITICAL FIX)
|
||||
const destExtId = destStation.station?.extId;
|
||||
if (destExtId) {
|
||||
const results = await api.searchJourneys(
|
||||
originStation.extId,
|
||||
destExtId,
|
||||
found.eventTime,
|
||||
);
|
||||
setJourneys(results);
|
||||
const finalWalkLookupPending =
|
||||
settings.showWalkingOption &&
|
||||
destCoords.coords !== null &&
|
||||
destStation.station?.lat != null &&
|
||||
destStation.station?.lng != null &&
|
||||
!walkHook.walkRoute &&
|
||||
!walkHook.error;
|
||||
if (finalWalkLookupPending) {
|
||||
setJourneys([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const walkDurationSeconds = settings.showWalkingOption ? (walkHook.walkRoute?.duration ?? 0) : 0;
|
||||
const target = stationArrivalTarget(found.eventTime, settings.arrivalBufferMinutes, walkDurationSeconds);
|
||||
try {
|
||||
const results = await api.searchJourneys(originStation.extId, destExtId, target, { arriveBy: true });
|
||||
setJourneys(results);
|
||||
await setCachedJourneys(eventId, results);
|
||||
} catch {
|
||||
if (!cachedJourneys) throw new Error('Failed to load journeys');
|
||||
// Keep stale data, mark as offline
|
||||
}
|
||||
} else if (destStation.error) {
|
||||
setError(`Ziel-Station nicht auflösbar: ${destStation.error}`);
|
||||
setError(`Destination station not resolvable: ${destStation.error}`);
|
||||
}
|
||||
|
||||
// Fetch bike route if we have coordinates
|
||||
try {
|
||||
setLoadingBike(true);
|
||||
if (destCoords.coords && originStation.lat && originStation.lng) {
|
||||
const bike = await api.getBikeRoute(
|
||||
originStation.lat,
|
||||
originStation.lng,
|
||||
destCoords.coords.lat,
|
||||
destCoords.coords.lng,
|
||||
originStation.lat, originStation.lng,
|
||||
destCoords.coords.lat, destCoords.coords.lng,
|
||||
);
|
||||
setBikeRoute(bike);
|
||||
await setCachedBikeRoute(eventId, bike);
|
||||
}
|
||||
} catch {
|
||||
setBikeRoute(null);
|
||||
if (!cachedBike) setBikeRoute(null);
|
||||
} finally {
|
||||
setLoadingBike(false);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Laden');
|
||||
const msg = err instanceof Error ? err.message : 'Error loading';
|
||||
// If we have any cached data, show a soft offline warning instead of a hard error
|
||||
if (cachedJourneys || cachedBike || cachedWalk) {
|
||||
setError(`${msg} (showing cached data)`);
|
||||
} else {
|
||||
setError(msg);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [eventId, destStation.station, destStation.error, destCoords.coords]);
|
||||
}, [eventId, destStation.station, destStation.error, destCoords.coords, walkHook.walkRoute, walkHook.error]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
// Sync walk route from hook
|
||||
useEffect(() => {
|
||||
setWalkRoute(walkHook.walkRoute);
|
||||
setLoadingWalk(walkHook.loading);
|
||||
}, [walkHook.walkRoute, walkHook.loading]);
|
||||
if (walkHook.walkRoute) {
|
||||
setCachedWalkRoute(eventId, walkHook.walkRoute).catch(() => {});
|
||||
}
|
||||
}, [walkHook.walkRoute, walkHook.loading, eventId]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setBikeRoute(null);
|
||||
@@ -138,117 +186,66 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
||||
fetchData();
|
||||
};
|
||||
|
||||
// Use the shared departure time hook instead of inline calculation
|
||||
const departureInfo = useDepartureTime(
|
||||
event?.eventTime ?? new Date(),
|
||||
journeys.length > 0 ? journeys : null,
|
||||
bikeRoute?.duration ?? null,
|
||||
activeMode === 'train' && journeys.length > 0 ? 'train' : (activeMode === 'bike' && bikeRoute ? 'bike' : null),
|
||||
activeMode === 'train' && journeys.length > 0
|
||||
? 'train'
|
||||
: activeMode === 'bike' && bikeRoute
|
||||
? 'bike'
|
||||
: null,
|
||||
arrivalBufferMinutes,
|
||||
showWalkingOption ? (walkRoute?.duration ?? 0) : 0,
|
||||
originWalk.walkRoute?.duration ?? 0,
|
||||
);
|
||||
const leaveByTime = departureInfo.departureTime;
|
||||
|
||||
// Theme-based colors
|
||||
const colors = dark ? {
|
||||
background: '#1c1c1e',
|
||||
card: '#2c2c2e',
|
||||
text: '#f2f2f2',
|
||||
subtext: '#aeaeb2',
|
||||
accent: '#0a84ff',
|
||||
border: '#38383a',
|
||||
warning: '#ff9f0a',
|
||||
error: '#ff453a',
|
||||
} : {
|
||||
background: '#f2f2f7',
|
||||
card: '#ffffff',
|
||||
text: '#1c1c1e',
|
||||
subtext: '#8e8e93',
|
||||
accent: '#007AFF',
|
||||
border: '#e5e5ea',
|
||||
warning: '#FF9500',
|
||||
error: '#FF3B30',
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={[styles.center, { backgroundColor: colors.background }]}>
|
||||
<ActivityIndicator size="large" color={colors.accent} />
|
||||
<Text style={[styles.loadingText, { color: colors.subtext }]}>
|
||||
{destStation.loading && !event ? 'Ziel-Station wird aufgelöst…' : 'Termine werden geladen…'}
|
||||
{destStation.loading && !event ? 'Resolving destination station…' : 'Loading events…'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Disable bike mode if setting is off
|
||||
const bikeDisabled = !showBikeOption;
|
||||
const requestedMode: TransportMode = activeMode;
|
||||
const effectiveMode: TransportMode = bikeDisabled && requestedMode === 'bike' ? 'train' : requestedMode;
|
||||
const effectiveMode: TransportMode =
|
||||
bikeDisabled && activeMode === 'bike' ? 'train' : activeMode;
|
||||
|
||||
return (
|
||||
<ScrollView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
{/* Event header */}
|
||||
{event && (
|
||||
<View style={[styles.header, { backgroundColor: colors.card }]}>
|
||||
<Text style={[styles.eventTitle, { color: colors.text }]}>{event.title}</Text>
|
||||
<Text style={[styles.eventDest, { color: colors.subtext }]}>{event.destination}</Text>
|
||||
<Text style={[styles.eventTime, { color: colors.accent }]}>
|
||||
{event.eventTime.toLocaleString('de-AT', {
|
||||
weekday: 'long',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</Text>
|
||||
<Text style={[styles.source, { color: colors.subtext }]}>Quelle: {event.source}</Text>
|
||||
<EventHeader
|
||||
event={event}
|
||||
leaveByTime={departureInfo.departureTime}
|
||||
arrivalBufferMinutes={arrivalBufferMinutes}
|
||||
colors={colors}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Leave by / Arrive by / Buffer info */}
|
||||
<View style={styles.infoGrid}>
|
||||
<View style={styles.infoBox}>
|
||||
<Text style={[styles.infoLabel, { color: colors.subtext }]}>Losgehen um</Text>
|
||||
<Text style={[styles.infoValue, { color: colors.text }]}>
|
||||
{leaveByTime ? leaveByTime.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' }) : '—'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.infoBox}>
|
||||
<Text style={[styles.infoLabel, { color: colors.subtext }]}>Ankommen um</Text>
|
||||
<Text style={[styles.infoValue, { color: colors.text }]}>
|
||||
{new Date(event.eventTime.getTime() - arrivalBufferMinutes * 60_000).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.infoBox}>
|
||||
<Text style={[styles.infoLabel, { color: colors.subtext }]}>Puffer</Text>
|
||||
<Text style={[styles.infoValue, { color: colors.text }]}>
|
||||
{arrivalBufferMinutes} min
|
||||
</Text>
|
||||
</View>
|
||||
{error && (
|
||||
<View style={[styles.errorBanner, { backgroundColor: colors.error }]}>
|
||||
<View style={styles.bannerContent}>
|
||||
<FontAwesomeIcon icon={faTriangleExclamation} size={14} color="#fff" />
|
||||
<Text style={styles.bannerText}>{error}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<View style={[styles.errorBanner, { backgroundColor: colors.error }]}>
|
||||
<Text style={styles.errorBannerText}>⚠ {error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Origin status */}
|
||||
{!origin && !error && (
|
||||
<View style={[styles.warningBanner, { backgroundColor: colors.warning }]}>
|
||||
<Text style={styles.warningBannerText}>
|
||||
Keine Ursprungstation festgelegt.
|
||||
{' '}
|
||||
<Text style={styles.warningLink} onPress={() => navigation.navigate('Settings')}>
|
||||
Einstellungen öffnen
|
||||
<Text style={styles.bannerText}>
|
||||
No origin station set.{' '}
|
||||
<Text style={styles.bannerLink} onPress={() => navigation.navigate('Settings')}>
|
||||
Open settings
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Transport mode selector */}
|
||||
{origin && (
|
||||
<View style={[styles.modeSelector, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||
<TouchableOpacity
|
||||
@@ -259,15 +256,18 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
||||
onPress={() => setActiveMode('train')}
|
||||
>
|
||||
<View style={styles.modeHeader}>
|
||||
<Text style={[styles.modeLabel, { color: colors.text }]}>🚆 Zug</Text>
|
||||
<View style={styles.modeLabelRow}>
|
||||
<FontAwesomeIcon icon={faTrain} size={14} color={colors.text} />
|
||||
<Text style={[styles.modeLabel, { color: colors.text }]}>Train</Text>
|
||||
</View>
|
||||
{effectiveMode === 'train' && (
|
||||
<View style={[styles.activeBadge, { backgroundColor: colors.accent }]}>
|
||||
<Text style={styles.activeBadgeText}>Aktiv</Text>
|
||||
<Text style={styles.activeBadgeText}>Active</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={[styles.modeMeta, { color: colors.subtext }]}>
|
||||
{showWalkingOption ? 'Bahn + finaler Fußweg' : 'Nur Bahn'}
|
||||
{showWalkingOption ? 'Train + final walk' : 'Train only'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -281,148 +281,68 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
||||
disabled={bikeDisabled}
|
||||
>
|
||||
<View style={styles.modeHeader}>
|
||||
<Text style={[styles.modeLabel, { color: colors.text }]}>🚲 Rad</Text>
|
||||
<View style={styles.modeLabelRow}>
|
||||
<FontAwesomeIcon icon={faBicycle} size={14} color={colors.text} />
|
||||
<Text style={[styles.modeLabel, { color: colors.text }]}>Bike</Text>
|
||||
</View>
|
||||
{effectiveMode === 'bike' && (
|
||||
<View style={[styles.activeBadge, { backgroundColor: colors.accent }]}>
|
||||
<Text style={styles.activeBadgeText}>Aktiv</Text>
|
||||
<Text style={styles.activeBadgeText}>Active</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={[styles.modeMeta, { color: colors.subtext }]}>
|
||||
{bikeDisabled ? 'In Einstellungen deaktiviert' : loadingBike ? 'Route wird berechnet...' : 'Direktweg'}
|
||||
{bikeDisabled
|
||||
? 'Disabled in settings'
|
||||
: loadingBike
|
||||
? 'Calculating route...'
|
||||
: 'Direct route'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Journeys list (Train mode) */}
|
||||
{effectiveMode === 'train' && (
|
||||
<View style={[styles.journeys, { backgroundColor: colors.background }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Zugverbindungen</Text>
|
||||
{destStation.loading && (
|
||||
<View style={styles.centerBike}>
|
||||
<ActivityIndicator size="small" color={colors.accent} />
|
||||
<Text style={[styles.loadingText, { color: colors.subtext }]}>
|
||||
Ziel-Station wird aufgelöst…
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{journeys.length === 0 && !destStation.loading ? (
|
||||
<Text style={[styles.emptyText, { color: colors.subtext }]}>
|
||||
{origin ? 'Keine Verbindungen gefunden' : 'Ursprungstation festlegen'}
|
||||
</Text>
|
||||
) : (
|
||||
journeys.map((j) => (
|
||||
<View key={j.id} style={[styles.journeyCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||
<View style={styles.journeyRow}>
|
||||
<Text style={[styles.lineText, { color: colors.text }]}>
|
||||
{j.trains.length > 0 ? j.trains.join(', ') : '—'}
|
||||
</Text>
|
||||
{j.delay > 0 && (
|
||||
<Text style={[styles.delayBadge, { backgroundColor: colors.error }]}>+{j.delay} min</Text>
|
||||
)}
|
||||
{j.cancelled && (
|
||||
<Text style={[styles.cancelBadge, { backgroundColor: colors.text }]}>Storniert</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text style={[styles.departure, { color: colors.text }]}>
|
||||
Abfahrt: {new Date(j.sD).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
|
||||
{' '}
|
||||
(Plattform {j.platform || '—'})
|
||||
</Text>
|
||||
<Text style={[styles.arrival, { color: colors.subtext }]}>
|
||||
Ankunft: {new Date(j.sA).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
|
||||
{' '}
|
||||
({j.changes === 0 ? 'Direkt' : `${j.changes} Umst.`})
|
||||
</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Walk route section (when walking option enabled) */}
|
||||
{showWalkingOption && walkRoute && (
|
||||
<View style={[styles.walkCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||
<Text style={[styles.walkTitle, { color: colors.text }]}>🚶 Finaler Fußweg</Text>
|
||||
<View style={styles.walkRow}>
|
||||
<Text style={[styles.walkLabel, { color: colors.text }]}>⏱ Dauer</Text>
|
||||
<Text style={[styles.walkValue, { color: colors.accent }]}>{formatDuration(walkRoute.duration)}</Text>
|
||||
</View>
|
||||
<View style={styles.walkRow}>
|
||||
<Text style={[styles.walkLabel, { color: colors.text }]}>📏 Distanz</Text>
|
||||
<Text style={[styles.walkValue, { color: colors.accent }]}>{formatDistance(walkRoute.distance)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{showWalkingOption && loadingWalk && (
|
||||
<View style={styles.centerBike}>
|
||||
<ActivityIndicator size="small" color={colors.accent} />
|
||||
<Text style={[styles.loadingText, { color: colors.subtext }]}>
|
||||
Fußweg wird geladen…
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{event && effectiveMode === 'train' && (
|
||||
<JourneyList
|
||||
journeys={journeys}
|
||||
destStationLoading={destStation.loading}
|
||||
walkRoute={walkRoute}
|
||||
loadingWalk={loadingWalk}
|
||||
showWalkingOption={showWalkingOption}
|
||||
eventTime={event.eventTime}
|
||||
arrivalBufferMinutes={arrivalBufferMinutes}
|
||||
origin={origin}
|
||||
colors={colors}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bike route section (Bike mode) */}
|
||||
{effectiveMode === 'bike' && (
|
||||
<View style={[styles.journeys, { backgroundColor: colors.background }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Radroute</Text>
|
||||
{loadingBike ? (
|
||||
<View style={styles.centerBike}>
|
||||
<ActivityIndicator size="small" color={colors.accent} />
|
||||
<Text style={[styles.loadingText, { color: colors.subtext }]}>Radroute wird geladen…</Text>
|
||||
</View>
|
||||
) : bikeRoute ? (
|
||||
<View style={[styles.bikeCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||
<View style={styles.bikeRow}>
|
||||
<Text style={[styles.bikeLabel, { color: colors.text }]}>⏱ Dauer</Text>
|
||||
<Text style={[styles.bikeValue, { color: colors.accent }]}>{formatDuration(bikeRoute.duration)}</Text>
|
||||
</View>
|
||||
<View style={styles.bikeRow}>
|
||||
<Text style={[styles.bikeLabel, { color: colors.text }]}>📏 Distanz</Text>
|
||||
<Text style={[styles.bikeValue, { color: colors.accent }]}>{formatDistance(bikeRoute.distance)}</Text>
|
||||
</View>
|
||||
<View style={[styles.mapPlaceholder, { backgroundColor: colors.background, borderColor: colors.border }]}>
|
||||
<Text style={[styles.mapPlaceholderText, { color: colors.subtext }]}>🗺 Karte (post-MVP)</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={[styles.emptyText, { color: colors.subtext }]}>
|
||||
{origin ? 'Keine Radroute verfügbar' : 'Ursprungstation festlegen'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<BikeSection
|
||||
bikeRoute={bikeRoute}
|
||||
loading={loadingBike}
|
||||
origin={origin}
|
||||
colors={colors}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* WienerLinien nearby stops */}
|
||||
{wienerLinien.stops.length > 0 && (
|
||||
<View style={[styles.journeys, { backgroundColor: colors.background }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>🚏 ÖPNV in der Nähe des Ziels</Text>
|
||||
{wienerLinien.loading ? (
|
||||
<View style={styles.centerBike}>
|
||||
<ActivityIndicator size="small" color={colors.accent} />
|
||||
<Text style={[styles.loadingText, { color: colors.subtext }]}>Haltestellen werden geladen…</Text>
|
||||
</View>
|
||||
) : (
|
||||
wienerLinien.stops.slice(0, 5).map((stop) => (
|
||||
<View key={stop.id} style={[styles.stopCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||
<Text style={[styles.stopName, { color: colors.text }]}>{stop.name}</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
{wienerLinien.error && (
|
||||
<Text style={[styles.emptyText, { color: colors.subtext }]}>{wienerLinien.error}</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
<NearbyStops
|
||||
stops={wienerLinien.stops}
|
||||
departures={wienerLinien.departures}
|
||||
loading={wienerLinien.loading}
|
||||
error={wienerLinien.error}
|
||||
colors={colors}
|
||||
/>
|
||||
|
||||
{/* Refresh */}
|
||||
<TouchableOpacity style={[styles.refreshBtn, { backgroundColor: colors.border }]} onPress={handleRefresh}>
|
||||
<Text style={[styles.refreshBtnText, { color: colors.text }]}>🔄 Neu laden</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.refreshBtn, { backgroundColor: colors.border }]}
|
||||
onPress={handleRefresh}
|
||||
>
|
||||
<View style={styles.refreshBtnContent}>
|
||||
<FontAwesomeIcon icon={faArrowsRotate} size={14} color={colors.text} />
|
||||
<Text style={[styles.refreshBtnText, { color: colors.text }]}>Refresh</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Bottom padding for scroll */}
|
||||
<View style={{ height: 40 }} />
|
||||
</ScrollView>
|
||||
);
|
||||
@@ -432,51 +352,27 @@ const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||
loadingText: { marginTop: 12, fontSize: 15 },
|
||||
header: { padding: 20, marginBottom: 12 },
|
||||
eventTitle: { fontSize: 22, fontWeight: '700' },
|
||||
eventDest: { fontSize: 16, marginTop: 4 },
|
||||
eventTime: { fontSize: 14, marginTop: 8 },
|
||||
source: { fontSize: 12, marginTop: 4 },
|
||||
infoGrid: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 16, paddingTop: 16, borderTopWidth: 1, borderTopColor: '#e5e5ea' },
|
||||
infoBox: { alignItems: 'center' },
|
||||
infoLabel: { fontSize: 11, fontWeight: '600', textTransform: 'uppercase' as const, letterSpacing: 1 },
|
||||
infoValue: { fontSize: 16, fontWeight: '700', marginTop: 4 },
|
||||
errorBanner: { padding: 12, marginBottom: 12 },
|
||||
errorBannerText: { color: '#fff', fontSize: 14 },
|
||||
warningBanner: { padding: 12, marginBottom: 12 },
|
||||
warningBannerText: { color: '#fff', fontSize: 14 },
|
||||
warningLink: { color: '#fff', fontWeight: '700', textDecorationLine: 'underline' },
|
||||
modeSelector: { flexDirection: 'row', padding: 12, gap: 12, marginBottom: 12, borderWidth: 1, borderRadius: 12 },
|
||||
bannerContent: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
bannerText: { color: '#fff', fontSize: 14 },
|
||||
bannerLink: { color: '#fff', fontWeight: '700', textDecorationLine: 'underline' },
|
||||
modeSelector: {
|
||||
flexDirection: 'row',
|
||||
padding: 12,
|
||||
gap: 12,
|
||||
marginBottom: 12,
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
},
|
||||
modeButton: { flex: 1, padding: 12, borderRadius: 10, borderWidth: 1, borderColor: 'transparent' },
|
||||
modeHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||
modeLabelRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||
modeLabel: { fontSize: 15, fontWeight: '600' },
|
||||
modeMeta: { fontSize: 11, marginTop: 4 },
|
||||
activeBadge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 10 },
|
||||
activeBadgeText: { color: '#fff', fontSize: 10, fontWeight: '700' },
|
||||
journeys: { padding: 20 },
|
||||
sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 12 },
|
||||
emptyText: { fontSize: 14 },
|
||||
journeyCard: { borderRadius: 10, padding: 14, marginBottom: 10, borderWidth: 1 },
|
||||
journeyRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||
lineText: { fontSize: 16, fontWeight: '600' },
|
||||
delayBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
|
||||
cancelBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
|
||||
departure: { fontSize: 13, marginTop: 6 },
|
||||
arrival: { fontSize: 13, marginTop: 2 },
|
||||
walkCard: { borderRadius: 10, padding: 14, marginTop: 10, borderWidth: 1 },
|
||||
walkTitle: { fontSize: 15, fontWeight: '600', marginBottom: 8 },
|
||||
walkRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 },
|
||||
walkLabel: { fontSize: 14, fontWeight: '500' },
|
||||
walkValue: { fontSize: 14, fontWeight: '600' },
|
||||
centerBike: { alignItems: 'center', gap: 8 },
|
||||
bikeCard: { borderRadius: 10, padding: 14, borderWidth: 1 },
|
||||
bikeRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 6 },
|
||||
bikeLabel: { fontSize: 15, fontWeight: '500' },
|
||||
bikeValue: { fontSize: 15, fontWeight: '600' },
|
||||
mapPlaceholder: { marginTop: 10, height: 100, borderRadius: 8, justifyContent: 'center', alignItems: 'center', borderWidth: 1 },
|
||||
mapPlaceholderText: { fontSize: 14 },
|
||||
stopCard: { borderRadius: 10, padding: 12, marginBottom: 8, borderWidth: 1 },
|
||||
stopName: { fontSize: 14, fontWeight: '500' },
|
||||
refreshBtn: { alignSelf: 'center', marginTop: 20, paddingVertical: 12, paddingHorizontal: 24, borderRadius: 12 },
|
||||
refreshBtnContent: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
refreshBtnText: { fontSize: 15, fontWeight: '600' },
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
FlatList,
|
||||
RefreshControl,
|
||||
@@ -7,46 +7,56 @@ import {
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome';
|
||||
import { faPenToSquare } from '@fortawesome/free-solid-svg-icons';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import type { RouteProp } from '@react-navigation/native';
|
||||
import { loadEvents, removeEvent } from '../store/eventStore';
|
||||
import { calculateCountdown } from '@timetoleave/core';
|
||||
import type { Event as CalendarEvent } from '@timetoleave/core';
|
||||
import { loadEvents, loadNotificationSettings, loadOriginStation } from '../store/eventStore';
|
||||
import { calculateCountdown, formatTime } from '@timetoleave/core';
|
||||
import type { Event as CalendarEvent, Journey, Station } from '@timetoleave/core';
|
||||
import type { RootStack } from '../types/navigation';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
import { useColors } from '../hooks/useColors';
|
||||
import { useDepartureTime } from '../hooks/useDepartureTime';
|
||||
import { useDestinationStation } from '../hooks/useDestinationStation';
|
||||
import { useGeocode } from '../hooks/useGeocode';
|
||||
import { useOriginStationWalk } from '../hooks/useOriginStationWalk';
|
||||
import { useWalkRoute } from '../hooks/useWalkRoute';
|
||||
import { api } from '../services/api';
|
||||
|
||||
type ScreenProps = {
|
||||
navigation: NativeStackNavigationProp<RootStack, 'EventList'>;
|
||||
route: RouteProp<RootStack, 'EventList'>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Home screen showing a scrollable list of upcoming events with leave-time
|
||||
* countdowns. Pull-to-refresh reloads events from storage. Reloads
|
||||
* automatically when the screen gains focus so edits are reflected.
|
||||
*/
|
||||
export function EventListScreen({ navigation }: ScreenProps) {
|
||||
const { dark } = useTheme();
|
||||
const colors = useColors();
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [origin, setOrigin] = useState<Station | null>(null);
|
||||
const [arrivalBufferMinutes, setArrivalBufferMinutes] = useState(5);
|
||||
const [showWalkingOption, setShowWalkingOption] = useState(true);
|
||||
const [journeys, setJourneys] = useState<Journey[]>([]);
|
||||
const [journeysLoading, setJourneysLoading] = useState(false);
|
||||
const [journeysError, setJourneysError] = useState<string | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
// Force countdown recalculation periodically
|
||||
const [, setTick] = useState(0);
|
||||
|
||||
const colors = dark ? {
|
||||
background: '#1c1c1e',
|
||||
card: '#2c2c2e',
|
||||
text: '#f2f2f2',
|
||||
subtext: '#aeaeb2',
|
||||
accent: '#0a84ff',
|
||||
delete: '#ff453a',
|
||||
} : {
|
||||
background: '#f2f2f7',
|
||||
card: '#ffffff',
|
||||
text: '#1c1c1e',
|
||||
subtext: '#8e8e93',
|
||||
accent: '#007AFF',
|
||||
delete: '#FF3B30',
|
||||
};
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const list = await loadEvents();
|
||||
const [list, originStation, settings] = await Promise.all([
|
||||
loadEvents(),
|
||||
loadOriginStation(),
|
||||
loadNotificationSettings(),
|
||||
]);
|
||||
setEvents(list);
|
||||
setOrigin(originStation);
|
||||
setArrivalBufferMinutes(settings.arrivalBufferMinutes);
|
||||
setShowWalkingOption(settings.showWalkingOption);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
@@ -67,13 +77,109 @@ export function EventListScreen({ navigation }: ScreenProps) {
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: CalendarEvent }) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const countdown = calculateCountdown(item.eventTime);
|
||||
const upcomingEvent = useMemo(() => {
|
||||
const now = Date.now();
|
||||
return events
|
||||
.filter((event) => event.eventTime.getTime() >= now)
|
||||
.sort((a, b) => a.eventTime.getTime() - b.eventTime.getTime())[0] ?? null;
|
||||
}, [events]);
|
||||
|
||||
// Derive a simple status — journeys aren't loaded on the list screen for MVP
|
||||
// so we show countdown-based status instead
|
||||
const status = countdown.urgent ? 'Bald!' : countdown.label;
|
||||
const destStation = useDestinationStation(upcomingEvent?.destination);
|
||||
const destCoords = useGeocode(upcomingEvent?.destination);
|
||||
const walkHook = useWalkRoute(
|
||||
destStation.station?.lat,
|
||||
destStation.station?.lng,
|
||||
destCoords.coords?.lat,
|
||||
destCoords.coords?.lng,
|
||||
);
|
||||
const destinationStationExtId = destStation.station?.extId;
|
||||
const destinationStationLat = destStation.station?.lat;
|
||||
const destinationStationLng = destStation.station?.lng;
|
||||
const destinationLat = destCoords.coords?.lat;
|
||||
const destinationLng = destCoords.coords?.lng;
|
||||
const originExtId = origin?.extId;
|
||||
const originWalk = useOriginStationWalk(origin);
|
||||
const originWalkDurationSeconds = originWalk.walkRoute?.duration ?? 0;
|
||||
const walkDurationSeconds = showWalkingOption ? (walkHook.walkRoute?.duration ?? 0) : 0;
|
||||
const departureInfo = useDepartureTime(
|
||||
upcomingEvent?.eventTime ?? new Date(),
|
||||
journeys.length > 0 ? journeys : null,
|
||||
null,
|
||||
journeys.length > 0 ? 'train' : null,
|
||||
arrivalBufferMinutes,
|
||||
walkDurationSeconds,
|
||||
originWalkDurationSeconds,
|
||||
);
|
||||
const selectedJourney = useMemo(() => {
|
||||
if (!departureInfo.departureTime) return null;
|
||||
const trainDepartureTime = departureInfo.departureTime.getTime() + originWalkDurationSeconds * 1000;
|
||||
return journeys.find((journey) => journey.rD.getTime() === trainDepartureTime) ?? null;
|
||||
}, [departureInfo.departureTime, journeys, originWalkDurationSeconds]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const fetchJourneys = async () => {
|
||||
if (!upcomingEvent || !originExtId || !destinationStationExtId) return;
|
||||
|
||||
const finalWalkPending =
|
||||
showWalkingOption &&
|
||||
destinationLat != null &&
|
||||
destinationLng != null &&
|
||||
destinationStationLat != null &&
|
||||
destinationStationLng != null &&
|
||||
walkHook.loading &&
|
||||
!walkHook.walkRoute &&
|
||||
!walkHook.error;
|
||||
if (finalWalkPending) return;
|
||||
|
||||
setJourneys((current) => (current.length > 0 ? [] : current));
|
||||
setJourneysError((current) => (current === null ? current : null));
|
||||
setJourneysLoading(true);
|
||||
try {
|
||||
const target = new Date(
|
||||
upcomingEvent.eventTime.getTime() -
|
||||
arrivalBufferMinutes * 60_000 -
|
||||
walkDurationSeconds * 1000,
|
||||
);
|
||||
const results = await api.searchJourneys(originExtId, destinationStationExtId, target, { arriveBy: true });
|
||||
if (!isMounted) return;
|
||||
setJourneys(results);
|
||||
} catch (err) {
|
||||
if (!isMounted) return;
|
||||
setJourneysError(err instanceof Error ? err.message : 'Connections could not be loaded');
|
||||
} finally {
|
||||
if (isMounted) setJourneysLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchJourneys();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [
|
||||
upcomingEvent,
|
||||
originExtId,
|
||||
destinationStationExtId,
|
||||
destinationStationLat,
|
||||
destinationStationLng,
|
||||
destinationLat,
|
||||
destinationLng,
|
||||
walkHook.loading,
|
||||
walkHook.walkRoute,
|
||||
walkHook.error,
|
||||
showWalkingOption,
|
||||
arrivalBufferMinutes,
|
||||
walkDurationSeconds,
|
||||
]);
|
||||
|
||||
const renderItem = ({ item }: { item: CalendarEvent }) => {
|
||||
const leaveBy = departureInfo.departureTime;
|
||||
const leaveCountdown = leaveBy ? calculateCountdown(leaveBy) : null;
|
||||
const leaveCountdownLabel = leaveCountdown?.label;
|
||||
const leaveByLabel = leaveBy ? formatTime(leaveBy) : null;
|
||||
const trainLabel = selectedJourney?.trains.length ? selectedJourney.trains.join(', ') : 'Searching for train connection';
|
||||
|
||||
return (
|
||||
<View style={styles.cardWrapper}>
|
||||
@@ -84,50 +190,62 @@ export function EventListScreen({ navigation }: ScreenProps) {
|
||||
>
|
||||
<View style={[styles.card, { backgroundColor: colors.card }]}>
|
||||
<View style={styles.dotRow}>
|
||||
<View style={[styles.dot, { backgroundColor: countdown.urgent ? colors.delete : '#34C759' }]} />
|
||||
<View style={[styles.dot, { backgroundColor: leaveCountdown?.urgent ? colors.delete : '#34C759' }]} />
|
||||
<Text style={[styles.title, { color: colors.text }]}>{item.title}</Text>
|
||||
<Text style={[styles.badge, { color: countdown.urgent ? colors.delete : colors.accent }]}>
|
||||
{countdown.label}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[styles.subtitle, { color: colors.subtext }]}>{item.destination}</Text>
|
||||
<Text style={[styles.time, { color: colors.accent }]}>
|
||||
{item.eventTime.toLocaleString('de-AT', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
<Text style={[styles.leaveLabel, { color: colors.subtext }]}>Time To Leave</Text>
|
||||
<Text style={[styles.leaveTime, { color: leaveByLabel ? colors.accent : colors.subtext }]}>
|
||||
{leaveByLabel ?? '--:--'}
|
||||
</Text>
|
||||
<Text style={[styles.status, { color: countdown.urgent ? colors.delete : '#34C759' }]}>{status}</Text>
|
||||
<Text style={[styles.leaveLabel, { color: colors.subtext }]}>Leave in</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.leaveTime,
|
||||
{ color: leaveCountdownLabel ? colors.accent : colors.subtext },
|
||||
]}
|
||||
>
|
||||
{leaveCountdownLabel
|
||||
? leaveCountdownLabel
|
||||
: journeysLoading || destStation.loading
|
||||
? '--'
|
||||
: 'No connection'}
|
||||
</Text>
|
||||
<View style={[styles.trainBox, { borderColor: colors.border }]}>
|
||||
<Text style={[styles.trainTitle, { color: colors.text }]} numberOfLines={2}>
|
||||
{journeysError ? 'Train connection unavailable' : trainLabel}
|
||||
</Text>
|
||||
{selectedJourney ? (
|
||||
<Text style={[styles.trainMeta, { color: colors.subtext }]}>
|
||||
Departure {formatTime(selectedJourney.rD)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={[styles.trainMeta, { color: journeysError ? colors.delete : colors.subtext }]}>
|
||||
{journeysError ?? (origin ? 'Best connection for the next event' : 'Set origin station')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.navigate('AddEvent', { editEventId: item.id })}
|
||||
style={styles.editIconBtn}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPenToSquare} size={16} color={colors.subtext} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Edit button */}
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.navigate('AddEvent', { editEventId: item.id })}
|
||||
style={styles.editBtn}
|
||||
>
|
||||
<Text style={[styles.editText, { color: colors.accent }]}>✏️ Bearbeiten</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Delete button */}
|
||||
<TouchableOpacity onPress={() => removeEvent(item.id, reload)} style={styles.deleteBtn}>
|
||||
<Text style={[styles.deleteText, { color: colors.delete }]}>Entfernen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
if (events.length === 0) {
|
||||
if (!upcomingEvent) {
|
||||
return (
|
||||
<View style={[styles.center, { backgroundColor: colors.background }]}>
|
||||
<Text style={[styles.empty, { color: colors.subtext }]}>Keine Termine</Text>
|
||||
<Text style={[styles.empty, { color: colors.subtext }]}>No upcoming events</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.addBtn}
|
||||
onPress={() => navigation.navigate('AddEvent')}
|
||||
>
|
||||
<Text style={styles.addBtnText}>+ Termin hinzufügen</Text>
|
||||
<Text style={styles.addBtnText}>+ Add event</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
@@ -135,22 +253,8 @@ export function EventListScreen({ navigation }: ScreenProps) {
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.topBar}>
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.navigate('CalendarImport')}
|
||||
style={styles.topBtn}
|
||||
>
|
||||
<Text style={[styles.topBtnText, { color: colors.accent }]}>📅 Kalender</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.navigate('Settings')}
|
||||
style={styles.topBtn}
|
||||
>
|
||||
<Text style={[styles.topBtnText, { color: colors.accent }]}>⚙️ Einstellungen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<FlatList
|
||||
data={events}
|
||||
data={[upcomingEvent]}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={styles.list}
|
||||
@@ -170,9 +274,6 @@ export function EventListScreen({ navigation }: ScreenProps) {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
topBar: { flexDirection: 'row', justifyContent: 'flex-end', padding: 8, gap: 8 },
|
||||
topBtn: { paddingHorizontal: 12, paddingVertical: 6 },
|
||||
topBtnText: { fontSize: 15 },
|
||||
list: { padding: 12 },
|
||||
cardWrapper: { marginBottom: 12 },
|
||||
card: {
|
||||
@@ -190,14 +291,16 @@ const styles = StyleSheet.create({
|
||||
badge: { fontSize: 12, fontWeight: '600' },
|
||||
subtitle: { fontSize: 14, marginBottom: 4 },
|
||||
time: { fontSize: 13 },
|
||||
leaveLabel: { fontSize: 12, fontWeight: '600', marginTop: 18, textTransform: 'uppercase' },
|
||||
leaveTime: { fontSize: 52, lineHeight: 58, fontWeight: '800', marginTop: 2, marginBottom: 12 },
|
||||
trainBox: { borderWidth: 1, borderRadius: 10, padding: 12, marginBottom: 12 },
|
||||
trainTitle: { fontSize: 16, fontWeight: '700' },
|
||||
trainMeta: { fontSize: 13, marginTop: 6, lineHeight: 18 },
|
||||
status: { fontSize: 13, marginTop: 2, fontWeight: '500' },
|
||||
editBtn: { alignSelf: 'flex-start', marginTop: 4 },
|
||||
editText: { fontSize: 13, fontWeight: '500' },
|
||||
deleteBtn: { alignSelf: 'flex-start', marginTop: 2, marginBottom: 4 },
|
||||
deleteText: { fontSize: 13 },
|
||||
editIconBtn: { alignSelf: 'flex-end', padding: 4 },
|
||||
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||
empty: { fontSize: 20, marginBottom: 16 },
|
||||
addBtn: { backgroundColor: '#007AFF', paddingVertical: 12, paddingHorizontal: 24, borderRadius: 12 },
|
||||
addBtn: { backgroundColor: '#8B5CF6', paddingVertical: 12, paddingHorizontal: 24, borderRadius: 12 },
|
||||
addBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
|
||||
fab: {
|
||||
position: 'absolute',
|
||||
@@ -206,7 +309,7 @@ const styles = StyleSheet.create({
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: '#007AFF',
|
||||
backgroundColor: '#8B5CF6',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
@@ -9,6 +10,8 @@ import {
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome';
|
||||
import { faCheck, faChevronDown, faChevronUp, faLocationDot, faXmark } from '@fortawesome/free-solid-svg-icons';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import type { RouteProp } from '@react-navigation/native';
|
||||
import * as Location from 'expo-location';
|
||||
@@ -16,6 +19,7 @@ import { loadOriginStation, saveOriginStation, loadNotificationSettings, saveNot
|
||||
import { api } from '../services/api';
|
||||
import type { Station, ReminderSettings } from '@timetoleave/core';
|
||||
import type { RootStack } from '../types/navigation';
|
||||
import { useColors } from '../hooks/useColors';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
|
||||
type ScreenProps = {
|
||||
@@ -23,12 +27,21 @@ type ScreenProps = {
|
||||
route: RouteProp<RootStack, 'Settings'>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Settings screen for managing the origin station, notification preferences,
|
||||
* appearance (dark/light mode), and advanced options (walk/bike toggles).
|
||||
*
|
||||
* Station search is debounced by 400 ms. When the origin changes, all
|
||||
* scheduled push notifications are recalculated to use the new departure station.
|
||||
*/
|
||||
export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
||||
const { dark, toggle: toggleTheme } = useTheme();
|
||||
const colors = useColors();
|
||||
const [origin, setOrigin] = useState<Station | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<Station[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchError, setSearchError] = useState<string | null>(null);
|
||||
const [notifSettings, setNotifSettings] = useState<ReminderSettings>({
|
||||
bufferMinutes: 30,
|
||||
enabled: true,
|
||||
@@ -40,24 +53,6 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
||||
const [locPermission, setLocPermission] = useState<'granted' | 'denied' | 'prompt'>('prompt');
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const colors = dark ? {
|
||||
background: '#1c1c1e',
|
||||
card: '#2c2c2e',
|
||||
text: '#f2f2f2',
|
||||
subtext: '#aeaeb2',
|
||||
accent: '#0a84ff',
|
||||
border: '#38383a',
|
||||
success: '#30d158',
|
||||
} : {
|
||||
background: '#f2f2f7',
|
||||
card: '#ffffff',
|
||||
text: '#1c1c1e',
|
||||
subtext: '#8e8e93',
|
||||
accent: '#007AFF',
|
||||
border: '#e5e5ea',
|
||||
success: '#34C759',
|
||||
};
|
||||
|
||||
// Load persisted data on mount
|
||||
useEffect(() => {
|
||||
loadOriginStation().then(setOrigin);
|
||||
@@ -72,14 +67,18 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
||||
const searchStation = useCallback(async (q: string) => {
|
||||
if (q.trim().length < 2) {
|
||||
setResults([]);
|
||||
setSearchError(null);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
setSearchError(null);
|
||||
try {
|
||||
const stations = await api.searchStation(q.trim());
|
||||
setResults(stations);
|
||||
if (stations.length === 0) setSearchError('No stations found.');
|
||||
} catch {
|
||||
setResults([]);
|
||||
setSearchError('API not reachable. Is the server running on your device? Check EXPO_PUBLIC_API_BASE_URL in .env.');
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
@@ -87,17 +86,23 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
||||
|
||||
const onQueryChange = (text: string) => {
|
||||
setQuery(text);
|
||||
setSearchError(null);
|
||||
// Proper debounce using useRef — no `any`
|
||||
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
|
||||
searchTimerRef.current = setTimeout(() => searchStation(text), 400);
|
||||
};
|
||||
|
||||
const selectStation = (station: Station) => {
|
||||
const selectStation = async (station: Station) => {
|
||||
setOrigin(station);
|
||||
setQuery(station.name);
|
||||
setResults([]);
|
||||
saveOriginStation(station);
|
||||
rescheduleAllNotifications(); // Recalculate when origin changes
|
||||
try {
|
||||
await saveOriginStation(station);
|
||||
await rescheduleAllNotifications();
|
||||
Alert.alert('Station saved', station.name);
|
||||
} catch {
|
||||
Alert.alert('Error', 'Station could not be saved.');
|
||||
}
|
||||
};
|
||||
|
||||
const useCurrentLocation = async () => {
|
||||
@@ -106,7 +111,7 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
||||
setLocPermission(status === 'granted' ? 'granted' : 'denied');
|
||||
|
||||
if (status !== 'granted') {
|
||||
Alert.alert('Berechtigung erforderlich', 'Standortzugriff ist nötig für die automatische Stationssuche.');
|
||||
Alert.alert('Permission required', 'Location access is required for automatic station search.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -114,31 +119,61 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
||||
const userLat = loc.coords.latitude;
|
||||
const userLng = loc.coords.longitude;
|
||||
|
||||
// Find real public-transport stops near the user's GPS coordinates
|
||||
// via the WienerLinien nearby-stops proxy.
|
||||
const stops = await api.findNearbyStops(userLat, userLng, 2000);
|
||||
if (stops.length === 0) {
|
||||
Alert.alert('Keine Station gefunden', 'Kein ÖPNV-Halt in der Nähe gefunden.');
|
||||
// Try the WienerLinien nearby-stops proxy first
|
||||
let stops: Awaited<ReturnType<typeof api.findNearbyStops>> | null = null;
|
||||
let apiReachable = false;
|
||||
try {
|
||||
stops = await api.findNearbyStops(userLat, userLng, 2000);
|
||||
apiReachable = true;
|
||||
} catch {
|
||||
// API unavailable, will fall back to HAFAS LocMatch below
|
||||
}
|
||||
|
||||
// If nearby-stops returned results, pick the closest one
|
||||
if (stops && stops.length > 0) {
|
||||
const closest = stops.reduce((best, candidate) => {
|
||||
const bestDist = Math.hypot(best.lat - userLat, best.lng - userLng);
|
||||
const candDist = Math.hypot(candidate.lat - userLat, candidate.lng - userLng);
|
||||
return candDist < bestDist ? candidate : best;
|
||||
}, stops[0]);
|
||||
|
||||
const station: Station = {
|
||||
name: closest.name,
|
||||
extId: closest.id,
|
||||
lat: userLat,
|
||||
lng: userLng,
|
||||
};
|
||||
await selectStation(station);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pick the closest stop to the user's actual position
|
||||
const closest = stops.reduce((best, candidate) => {
|
||||
const bestDist = Math.hypot(best.lat - userLat, best.lng - userLng);
|
||||
const candDist = Math.hypot(candidate.lat - userLat, candidate.lng - userLng);
|
||||
return candDist < bestDist ? candidate : best;
|
||||
}, stops[0]);
|
||||
// Fallback: use HAFAS LocMatch directly (same pattern as the web app)
|
||||
try {
|
||||
const nearestStation = await api.findNearestStationByCoords(userLat, userLng);
|
||||
if (!nearestStation) {
|
||||
Alert.alert('No station found', 'No public transport stop found nearby.');
|
||||
return;
|
||||
}
|
||||
await selectStation({
|
||||
...nearestStation,
|
||||
lat: userLat,
|
||||
lng: userLng,
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// API not reachable
|
||||
}
|
||||
|
||||
// Build a Station with the real stop id as extId — HAFAS can look this up.
|
||||
const station: Station = {
|
||||
name: closest.name,
|
||||
extId: closest.id,
|
||||
lat: closest.lat,
|
||||
lng: closest.lng,
|
||||
};
|
||||
selectStation(station);
|
||||
if (!apiReachable) {
|
||||
Alert.alert(
|
||||
'API not reachable',
|
||||
'The server could not be reached. Make sure EXPO_PUBLIC_API_BASE_URL in your .env file points to the LAN IP of your development machine (e.g. http://192.168.1.x:3000) and not localhost.'
|
||||
);
|
||||
} else {
|
||||
Alert.alert('No station found', 'No public transport stop found nearby.');
|
||||
}
|
||||
} catch (_err) {
|
||||
Alert.alert('Fehler', 'Standort konnte nicht ermittelt werden.');
|
||||
Alert.alert('Error', 'Location detection failed. Please check the permissions in your system settings.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -188,36 +223,43 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<ScrollView
|
||||
style={[styles.container, { backgroundColor: colors.background }]}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Appearance */}
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Erscheinungsbild</Text>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Appearance</Text>
|
||||
<View style={styles.settingRow}>
|
||||
<Text style={[styles.settingLabel, { color: colors.text }]}>Dunkelmodus</Text>
|
||||
<Text style={[styles.settingLabel, { color: colors.text }]}>Dark mode</Text>
|
||||
<Switch
|
||||
value={dark}
|
||||
onValueChange={toggleTheme}
|
||||
trackColor={{ true: colors.accent, false: colors.border }}
|
||||
accessibilityLabel="Dunkelmodus umschalten"
|
||||
accessibilityLabel="Toggle dark mode"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Origin Station */}
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Ursprungstation</Text>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Origin station</Text>
|
||||
<TextInput
|
||||
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
|
||||
placeholder="Station suchen …"
|
||||
placeholder="Search station …"
|
||||
placeholderTextColor={colors.subtext}
|
||||
value={query}
|
||||
onChangeText={onQueryChange}
|
||||
autoCapitalize="words"
|
||||
accessibilityLabel="Station suchen"
|
||||
accessibilityLabel="Search station"
|
||||
/>
|
||||
{searching && <ActivityIndicator style={{ marginVertical: 8 }} color={colors.accent} />}
|
||||
{searchError && (
|
||||
<Text style={[styles.errorText, { color: '#ff453a' }]}>{searchError}</Text>
|
||||
)}
|
||||
{origin && (
|
||||
<Text style={[styles.currentStation, { color: colors.success }]}>Aktuell: {origin.name}</Text>
|
||||
<Text style={[styles.currentStation, { color: colors.success }]}>Current: {origin.name}</Text>
|
||||
)}
|
||||
|
||||
{results.map((s) => (
|
||||
@@ -226,84 +268,104 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
|
||||
<TouchableOpacity style={[styles.locBtn, { backgroundColor: dark ? '#1a3a5c' : '#e8f4fd' }]} onPress={useCurrentLocation}>
|
||||
<Text style={[styles.locBtnText, { color: colors.accent }]}>📍 Aktuelle Position verwenden</Text>
|
||||
<TouchableOpacity style={[styles.locBtn, { backgroundColor: colors.highlight }]} onPress={useCurrentLocation}>
|
||||
<View style={styles.buttonContent}>
|
||||
<FontAwesomeIcon icon={faLocationDot} size={14} color={colors.accent} />
|
||||
<Text style={[styles.locBtnText, { color: colors.accent }]}>Use current location</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.locStatus, { color: colors.subtext }]}>
|
||||
Standort: {locPermission === 'granted' ? 'Erlaubt ✓' : locPermission === 'denied' ? 'Verweigert ✗' : 'Noch nicht angefragt'}
|
||||
</Text>
|
||||
<View style={styles.locStatusRow}>
|
||||
<Text style={[styles.locStatus, { color: colors.subtext }]}>Location:</Text>
|
||||
{locPermission === 'granted' ? (
|
||||
<>
|
||||
<Text style={[styles.locStatus, { color: colors.subtext }]}>Granted</Text>
|
||||
<FontAwesomeIcon icon={faCheck} size={11} color={colors.success} />
|
||||
</>
|
||||
) : locPermission === 'denied' ? (
|
||||
<>
|
||||
<Text style={[styles.locStatus, { color: colors.subtext }]}>Denied</Text>
|
||||
<FontAwesomeIcon icon={faXmark} size={11} color="#ff453a" />
|
||||
</>
|
||||
) : (
|
||||
<Text style={[styles.locStatus, { color: colors.subtext }]}>Not yet requested</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Notification Settings */}
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Benachrichtigungen</Text>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Notifications</Text>
|
||||
<View style={styles.settingRow}>
|
||||
<Text style={[styles.settingLabel, { color: colors.text }]}>Benachrichtigungen aktivieren</Text>
|
||||
<Text style={[styles.settingLabel, { color: colors.text }]}>Enable notifications</Text>
|
||||
<Switch
|
||||
value={notifSettings.enabled}
|
||||
onValueChange={toggleNotifications}
|
||||
trackColor={{ true: colors.accent, false: colors.border }}
|
||||
accessibilityLabel="Benachrichtigungen umschalten"
|
||||
accessibilityLabel="Toggle notifications"
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.settingLabel, { color: colors.text }]}>Pufferzeit (Minuten)</Text>
|
||||
<Text style={[styles.settingLabel, { color: colors.text }]}>Buffer time (minutes)</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.numberInput, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
|
||||
value={String(notifSettings.bufferMinutes)}
|
||||
onChangeText={updateBufferMinutes}
|
||||
keyboardType="numeric"
|
||||
accessibilityLabel="Pufferzeit in Minuten"
|
||||
accessibilityLabel="Buffer time in minutes"
|
||||
/>
|
||||
<Text style={[styles.hint, { color: colors.subtext }]}>
|
||||
Du wirst {notifSettings.bufferMinutes} Minuten vor der geplanten Abfahrt erinnert.
|
||||
You will be reminded {notifSettings.bufferMinutes} minutes before the scheduled departure.
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity style={[styles.advancedToggle, styles.locBtn, { backgroundColor: dark ? '#1a3a5c' : '#e8f4fd' }]} onPress={toggleAdvanced}>
|
||||
<Text style={[styles.locBtnText, { color: colors.accent }]}>
|
||||
{showAdvanced ? '↑ Weniger Optionen zeigen' : '↓ Mehr Optionen zeigen'}
|
||||
</Text>
|
||||
<TouchableOpacity style={[styles.advancedToggle, styles.locBtn, { backgroundColor: colors.highlight }]} onPress={toggleAdvanced}>
|
||||
<View style={styles.buttonContent}>
|
||||
<FontAwesomeIcon icon={showAdvanced ? faChevronUp : faChevronDown} size={13} color={colors.accent} />
|
||||
<Text style={[styles.locBtnText, { color: colors.accent }]}>
|
||||
{showAdvanced ? 'Show fewer options' : 'Show more options'}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{showAdvanced && (
|
||||
<View style={[styles.advancedSection, { borderTopColor: colors.border }]}>
|
||||
<Text style={[styles.settingLabel, { color: colors.text }]}>Ankunfts-Puffer (Minuten)</Text>
|
||||
<Text style={[styles.settingLabel, { color: colors.text }]}>Arrival buffer (minutes)</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.numberInput, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
|
||||
value={String(notifSettings.arrivalBufferMinutes)}
|
||||
onChangeText={updateArrivalBuffer}
|
||||
keyboardType="numeric"
|
||||
accessibilityLabel="Ankunfts-Puffer in Minuten"
|
||||
accessibilityLabel="Arrival buffer in minutes"
|
||||
/>
|
||||
<Text style={[styles.hint, { color: colors.subtext }]}>Wie viele Minuten vor der Event-Zeit du am Ziel ankommen möchtest</Text>
|
||||
<Text style={[styles.hint, { color: colors.subtext }]}>How many minutes before the event time you want to arrive at the destination</Text>
|
||||
|
||||
<View style={styles.settingRow}>
|
||||
<Text style={[styles.settingLabel, { color: colors.text }]}>Zu Fuß-Option anzeigen</Text>
|
||||
<Text style={[styles.settingLabel, { color: colors.text }]}>Show walking option</Text>
|
||||
<Switch
|
||||
value={notifSettings.showWalkingOption}
|
||||
onValueChange={toggleWalking}
|
||||
trackColor={{ true: colors.accent, false: colors.border }}
|
||||
accessibilityLabel="Zu Fuß-Option umschalten"
|
||||
accessibilityLabel="Toggle walking option"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.settingRow}>
|
||||
<Text style={[styles.settingLabel, { color: colors.text }]}>Fahrrad-Option anzeigen</Text>
|
||||
<Text style={[styles.settingLabel, { color: colors.text }]}>Show bike option</Text>
|
||||
<Switch
|
||||
value={notifSettings.showBikeOption}
|
||||
onValueChange={toggleBike}
|
||||
trackColor={{ true: colors.accent, false: colors.border }}
|
||||
accessibilityLabel="Fahrrad-Option umschalten"
|
||||
accessibilityLabel="Toggle bike option"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, padding: 20 },
|
||||
container: { flex: 1 },
|
||||
contentContainer: { flexGrow: 1, padding: 20 },
|
||||
section: { marginBottom: 24 },
|
||||
sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 10 },
|
||||
input: {
|
||||
@@ -327,7 +389,10 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
},
|
||||
locBtnText: { fontSize: 15, fontWeight: '500' },
|
||||
locStatus: { fontSize: 12, marginTop: 6 },
|
||||
buttonContent: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
locStatusRow: { flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 6 },
|
||||
locStatus: { fontSize: 12 },
|
||||
errorText: { fontSize: 13, marginTop: 6 },
|
||||
advancedToggle: {
|
||||
marginTop: 12,
|
||||
marginBottom: 12,
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { ApiClient } from '@timetoleave/api-client';
|
||||
|
||||
/**
|
||||
* Thin wrapper around the shared `ApiClient` class.
|
||||
* Reads the primary base URL from `EXPO_PUBLIC_API_BASE_URL` and falls back
|
||||
* to the Tailscale dev server when the primary server is unreachable.
|
||||
*/
|
||||
const baseUrl = process.env.EXPO_PUBLIC_API_BASE_URL ?? '';
|
||||
export const api = new ApiClient(baseUrl);
|
||||
const fallbackBaseUrl = 'http://100.103.83.12:3030';
|
||||
|
||||
export const api = new ApiClient([baseUrl, fallbackBaseUrl]);
|
||||
|
||||
@@ -1,41 +1,165 @@
|
||||
import * as Calendar from 'expo-calendar';
|
||||
import type { Event as CoreEvent } from '@timetoleave/core';
|
||||
import {
|
||||
faLink, faMobile, faCalendar, faBuilding, faRss, faHardDrive, faUser, faArrowsRotate, faCalendarDays,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import type { IconDefinition } from '@fortawesome/fontawesome-svg-core';
|
||||
import type { Event as CoreEvent, CalendarAccountType, CalendarSourceInfo, SelectableCalendar } from '@timetoleave/core';
|
||||
|
||||
/**
|
||||
* Native calendar integration for the mobile app.
|
||||
* Reads events from device calendars and converts them to our internal format.
|
||||
*
|
||||
* Only events that have a non-empty `location` field are included, since the
|
||||
* app requires a destination to compute routes.
|
||||
*
|
||||
* Supports calendar selection so the user can pick which calendars to sync.
|
||||
* CalDAV sources (DAVx5 on Android, Apple Calendar on iOS) are detected and
|
||||
* labelled automatically.
|
||||
*/
|
||||
|
||||
export async function ensureCalendarPermission(): Promise<boolean> {
|
||||
const { status } = await Calendar.requestCalendarPermissionsAsync();
|
||||
if (status !== 'granted')
|
||||
return false;
|
||||
// ── Source type metadata ──
|
||||
|
||||
return Calendar.isAvailableAsync();
|
||||
/** Maps expo-calendar account types to human-readable info. */
|
||||
const SOURCE_INFO: Record<string, CalendarSourceInfo> = {
|
||||
caldav: { label: 'CalDAV / DAVx', badge: 'CalDAV' },
|
||||
mobileme: { label: 'Apple Calendar', badge: 'Apple' },
|
||||
google: { label: 'Google Calendar', badge: 'Google' },
|
||||
exchange: { label: 'Microsoft Exchange', badge: 'Exchange' },
|
||||
subscriptions: { label: 'Subscribed', badge: 'Sub' },
|
||||
local: { label: 'On My Device', badge: 'Local' },
|
||||
carddav: { label: 'CardDAV', badge: 'CardDAV' },
|
||||
activesync: { label: 'ActiveSync', badge: 'Sync' },
|
||||
};
|
||||
|
||||
const DEFAULT_SOURCE_INFO: CalendarSourceInfo = { label: 'Other', badge: 'Other' };
|
||||
|
||||
/** FontAwesome icon for each calendar account type. */
|
||||
export const ACCOUNT_TYPE_ICONS: Record<string, IconDefinition> = {
|
||||
caldav: faLink,
|
||||
mobileme: faMobile,
|
||||
google: faCalendar,
|
||||
exchange: faBuilding,
|
||||
subscriptions: faRss,
|
||||
local: faHardDrive,
|
||||
carddav: faUser,
|
||||
activesync: faArrowsRotate,
|
||||
};
|
||||
|
||||
/** Returns the FA icon for a calendar account type, falling back to a generic calendar icon. */
|
||||
export function accountTypeIcon(type: string): IconDefinition {
|
||||
return ACCOUNT_TYPE_ICONS[type] ?? faCalendarDays;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch events from native calendars within a date range.
|
||||
* Returns events converted to our internal Event format.
|
||||
* Resolve the account type string from expo-calendar into a known
|
||||
* CalendarAccountType. Returns 'other' for unknown types.
|
||||
*/
|
||||
export async function fetchNativeEvents(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): Promise<CoreEvent[]> {
|
||||
function resolveAccountType(type: string | undefined | null): CalendarAccountType {
|
||||
if (!type) return 'other';
|
||||
const lower = type.toLowerCase();
|
||||
if (lower in SOURCE_INFO) return lower as CalendarAccountType;
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get source info for an expo-calendar source object.
|
||||
* DAVx5 on Android appears as `caldav`, Apple Calendar on iOS as `mobileme`.
|
||||
*/
|
||||
function sourceInfoFor(source: { type?: string } | undefined | null): CalendarSourceInfo {
|
||||
if (!source || !source.type) return DEFAULT_SOURCE_INFO;
|
||||
return SOURCE_INFO[source.type.toLowerCase()] ?? DEFAULT_SOURCE_INFO;
|
||||
}
|
||||
|
||||
// ── Permission ──
|
||||
|
||||
/** Requests calendar read permission and verifies the calendar service is available. */
|
||||
export async function ensureCalendarPermission(): Promise<boolean> {
|
||||
const { status } = await Calendar.requestCalendarPermissionsAsync();
|
||||
if (status !== 'granted') return false;
|
||||
return Calendar.isAvailableAsync();
|
||||
}
|
||||
|
||||
// ── Calendar listing ──
|
||||
|
||||
/**
|
||||
* Get all available calendars on the device, converted to a minimal format
|
||||
* suitable for the selection UI. Grouped by account type.
|
||||
*
|
||||
* DAVx5 calendars (Android) will show as CalDAV type.
|
||||
* Apple Calendar (iOS) will show as mobileme type.
|
||||
*/
|
||||
export async function getSelectableCalendars(): Promise<SelectableCalendar[]> {
|
||||
const available = await ensureCalendarPermission();
|
||||
if (!available) return [];
|
||||
|
||||
const calendars = await Calendar.getCalendarsAsync(Calendar.EntityTypes.EVENT);
|
||||
|
||||
return calendars.map((cal) => {
|
||||
const accountType = resolveAccountType(cal.source?.type);
|
||||
const sourceInfo = sourceInfoFor(cal.source);
|
||||
|
||||
return {
|
||||
id: cal.id,
|
||||
name: cal.name ?? 'Unnamed Calendar',
|
||||
accountType,
|
||||
sourceInfo,
|
||||
editable: cal.allowsModifications ?? false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Group calendars by account type for display in the selection UI.
|
||||
*/
|
||||
export function groupCalendarsByType(calendars: SelectableCalendar[]): Map<CalendarAccountType, SelectableCalendar[]> {
|
||||
const groups = new Map<CalendarAccountType, SelectableCalendar[]>();
|
||||
|
||||
for (const cal of calendars) {
|
||||
const existing = groups.get(cal.accountType) ?? [];
|
||||
existing.push(cal);
|
||||
groups.set(cal.accountType, existing);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
// ── Event fetching ──
|
||||
|
||||
/**
|
||||
* Fetch events from native calendars within a date range.
|
||||
* Returns events converted to our internal Event format.
|
||||
*
|
||||
* @param startDate - Start of the date range.
|
||||
* @param endDate - End of the date range.
|
||||
* @param calendarIds - Optional list of calendar IDs to fetch. If omitted,
|
||||
* fetches from all calendars.
|
||||
*/
|
||||
export async function fetchNativeEvents(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
calendarIds?: string[],
|
||||
): Promise<CoreEvent[]> {
|
||||
const available = await ensureCalendarPermission();
|
||||
if (!available) return [];
|
||||
|
||||
let calendars = await Calendar.getCalendarsAsync(Calendar.EntityTypes.EVENT);
|
||||
|
||||
if (calendarIds && calendarIds.length > 0) {
|
||||
calendars = calendars.filter((c) => calendarIds.includes(c.id));
|
||||
}
|
||||
|
||||
if (calendars.length === 0) return [];
|
||||
|
||||
const calendarIds = calendars.map((c) => c.id);
|
||||
const events = await Calendar.getEventsAsync(calendarIds, startDate, endDate);
|
||||
const ids = calendarIds ?? calendars.map((c) => c.id);
|
||||
const events = await Calendar.getEventsAsync(ids, startDate, endDate);
|
||||
|
||||
return events.map((evt) => ({
|
||||
id: evt.id,
|
||||
title: evt.title ?? 'Untitled Event',
|
||||
destination: evt.location ?? '',
|
||||
eventTime: typeof evt.startDate === 'string' ? new Date(evt.startDate) : (evt.startDate ?? new Date()),
|
||||
source: `native:${evt.calendarId}`,
|
||||
}));
|
||||
return events
|
||||
.filter((evt) => evt.location && evt.location.trim().length > 0)
|
||||
.map((evt) => ({
|
||||
id: evt.id,
|
||||
title: evt.title ?? 'Untitled Event',
|
||||
destination: evt.location!.trim(),
|
||||
eventTime: typeof evt.startDate === 'string' ? new Date(evt.startDate) : (evt.startDate ?? new Date()),
|
||||
source: `native:${evt.calendarId}`,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* Re-exports the stable public API from `expo-notifications`.
|
||||
* Using public exports instead of internal `/build/` paths to avoid
|
||||
* breaking when the Expo package is updated.
|
||||
*/
|
||||
|
||||
// Public API re-exports from expo-notifications
|
||||
// Using stable public exports instead of internal /build/ paths
|
||||
|
||||
@@ -11,6 +17,7 @@ export {
|
||||
cancelScheduledNotificationAsync,
|
||||
cancelAllScheduledNotificationsAsync,
|
||||
scheduleNotificationAsync,
|
||||
SchedulableTriggerInputTypes,
|
||||
} from 'expo-notifications';
|
||||
|
||||
// Re-export types from the public package
|
||||
@@ -18,5 +25,5 @@ export type {
|
||||
NotificationBehavior,
|
||||
NotificationRequest,
|
||||
NotificationRequestInput,
|
||||
SchedulableTriggerInput,
|
||||
SchedulableTriggerInputTypes as SchedulableTriggerInput,
|
||||
} from 'expo-notifications';
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Event, Journey } from '@timetoleave/core';
|
||||
|
||||
/**
|
||||
* Calculate the time at which a leave reminder should fire.
|
||||
*
|
||||
* If journey data is available, use the earliest non-cancelled departure and
|
||||
* subtract the reminder buffer. Otherwise, fall back to event time minus the
|
||||
* requested arrival buffer and reminder buffer.
|
||||
*/
|
||||
export function calculateLeaveByTime(
|
||||
event: Event,
|
||||
journeys: Journey[],
|
||||
arrivalBufferMinutes: number,
|
||||
reminderBufferMinutes: number,
|
||||
): Date {
|
||||
const validJourneys = journeys.filter((journey) => !journey.cancelled);
|
||||
|
||||
if (validJourneys.length > 0) {
|
||||
const earliestDeparture = validJourneys.reduce((earliest, journey) =>
|
||||
journey.rD.getTime() < earliest.rD.getTime() ? journey : earliest,
|
||||
);
|
||||
|
||||
return new Date(earliestDeparture.rD.getTime() - reminderBufferMinutes * 60_000);
|
||||
}
|
||||
|
||||
const totalBufferMs = (arrivalBufferMinutes + reminderBufferMinutes) * 60_000;
|
||||
return new Date(event.eventTime.getTime() - totalBufferMs);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Offline cache for API responses.
|
||||
*
|
||||
* Stores the last successful journey, bike-route, and walk-route results
|
||||
* per event (keyed by event id) so the detail screen can show stale data
|
||||
* when the device is offline.
|
||||
*/
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { Journey, BikeRoute, WalkRoute } from '@timetoleave/core';
|
||||
|
||||
const CACHE_PREFIX = '@timetoleave_cache_';
|
||||
const MAX_AGE_MS = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data: T;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
async function getCache<T>(key: string): Promise<T | null> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(CACHE_PREFIX + key);
|
||||
if (!raw) return null;
|
||||
const entry: CacheEntry<T> = JSON.parse(raw);
|
||||
if (Date.now() - entry.ts > MAX_AGE_MS) return null;
|
||||
return entry.data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function setCache<T>(key: string, data: T): Promise<void> {
|
||||
try {
|
||||
const entry: CacheEntry<T> = { data, ts: Date.now() };
|
||||
await AsyncStorage.setItem(CACHE_PREFIX + key, JSON.stringify(entry));
|
||||
} catch {
|
||||
// Silently fail on quota exceeded / privacy mode
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCachedJourneys(eventId: string): Promise<Journey[] | null> {
|
||||
const raw = await getCache<Array<Omit<Journey, 'sD' | 'rD' | 'sA' | 'rA'> & { sD: string; rD: string; sA: string; rA: string }>>(`journeys_${eventId}`);
|
||||
if (!raw) return null;
|
||||
return raw.map((j) => ({
|
||||
...j,
|
||||
sD: new Date(j.sD),
|
||||
rD: new Date(j.rD),
|
||||
sA: new Date(j.sA),
|
||||
rA: new Date(j.rA),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function setCachedJourneys(eventId: string, journeys: Journey[]): Promise<void> {
|
||||
const serializable = journeys.map((j) => ({
|
||||
...j,
|
||||
sD: j.sD.toISOString(),
|
||||
rD: j.rD.toISOString(),
|
||||
sA: j.sA.toISOString(),
|
||||
rA: j.rA.toISOString(),
|
||||
}));
|
||||
await setCache(`journeys_${eventId}`, serializable);
|
||||
}
|
||||
|
||||
export async function getCachedBikeRoute(eventId: string): Promise<BikeRoute | null> {
|
||||
return getCache(`bike_${eventId}`);
|
||||
}
|
||||
|
||||
export async function setCachedBikeRoute(eventId: string, route: BikeRoute): Promise<void> {
|
||||
await setCache(`bike_${eventId}`, route);
|
||||
}
|
||||
|
||||
export async function getCachedWalkRoute(eventId: string): Promise<WalkRoute | null> {
|
||||
return getCache(`walk_${eventId}`);
|
||||
}
|
||||
|
||||
export async function setCachedWalkRoute(eventId: string, route: WalkRoute): Promise<void> {
|
||||
await setCache(`walk_${eventId}`, route);
|
||||
}
|
||||
|
||||
export async function clearApiCache(): Promise<void> {
|
||||
const keys = await AsyncStorage.getAllKeys();
|
||||
const cacheKeys = keys.filter((k) => k.startsWith(CACHE_PREFIX));
|
||||
await AsyncStorage.multiRemove(cacheKeys);
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
/**
|
||||
* Persistent event store backed by AsyncStorage.
|
||||
*
|
||||
* Every mutation (add, update, remove) also reschedules push notifications
|
||||
* so the user is reminded at the correct departure time. Settings changes
|
||||
* (origin station, buffer) trigger a full notification reschedule.
|
||||
*/
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { Event, Station, ReminderSettings } from '@timetoleave/core';
|
||||
import { DEFAULT_ORIGIN_STATION, type Event, type Station, type ReminderSettings } from '@timetoleave/core';
|
||||
import * as Notifications from '../services/expoNotifications';
|
||||
|
||||
// ── Keys ───────────────────────────────────────────────────────
|
||||
@@ -7,6 +15,7 @@ import * as Notifications from '../services/expoNotifications';
|
||||
const EVENTS_KEY = '@timetoleave_events';
|
||||
const ORIGIN_KEY = '@timetoleave_origin';
|
||||
const NOTIFICATIONS_KEY = '@timetoleave_notifications';
|
||||
const SELECTED_CALENDARS_KEY = '@timetoleave_selected_calendars';
|
||||
|
||||
// ── Default notification settings ─────────────────────────────
|
||||
|
||||
@@ -20,6 +29,10 @@ const DEFAULT_NOTIFICATION_SETTINGS: ReminderSettings = {
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Revives `eventTime` strings from JSON storage back into Date objects.
|
||||
* Returns an empty array if the stored data is corrupted.
|
||||
*/
|
||||
function reviveDates(json: string): Event[] {
|
||||
try {
|
||||
const parsed = JSON.parse(json) as Array<Event & { eventTime: string }>;
|
||||
@@ -38,6 +51,11 @@ async function getNotificationSettings(): Promise<ReminderSettings> {
|
||||
// Notification scheduling utilities
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Calculate the leave-by time using a pessimistic fallback.
|
||||
* Because the store doesn't hold live journey data, it subtracts
|
||||
* both the arrival buffer and the notification buffer from the event time.
|
||||
*/
|
||||
async function calculateLeaveByTime(event: Event, arrivalBufferMinutes: number, bufferMinutes: number): Promise<Date> {
|
||||
// Calculate target arrival time (event time minus arrival buffer)
|
||||
const targetArrivalTime = new Date(event.eventTime);
|
||||
@@ -47,53 +65,53 @@ async function calculateLeaveByTime(event: Event, arrivalBufferMinutes: number,
|
||||
return new Date(targetArrivalTime.getTime() - bufferMinutes * 60 * 1000);
|
||||
}
|
||||
|
||||
async function scheduleEventNotification(event: Event): Promise<void> {
|
||||
const settings = await getNotificationSettings();
|
||||
if (!settings.enabled) {
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* Schedule three reminder notifications for an event:
|
||||
* 30 min, 10 min, and 0 min before the leave-by time.
|
||||
* Skips notifications that would fire in the past or more than 2 hours before
|
||||
* the event (push notifications are unreliable beyond that window).
|
||||
*/
|
||||
async function fireNotificationsForEvent(event: Event, leaveByTime: Date): Promise<void> {
|
||||
const REMINDERS_MIN = [30, 10, 0];
|
||||
const twoHoursBefore = new Date(event.eventTime.getTime() - 2 * 60 * 60 * 1000);
|
||||
const now = new Date();
|
||||
|
||||
const leaveByTime = await calculateLeaveByTime(event, settings.arrivalBufferMinutes, settings.bufferMinutes);
|
||||
const existing = await Notifications.getAllScheduledNotificationsAsync();
|
||||
const toCancel = existing.filter(n => n.content.data?.eventId === event.id);
|
||||
for (const notif of toCancel) {
|
||||
await Notifications.cancelScheduledNotificationAsync(notif.identifier);
|
||||
}
|
||||
|
||||
// Default reminders: 30min, 10min, and at leave-by time
|
||||
const defaultReminders = [30, 10, 0];
|
||||
|
||||
// Schedule notifications
|
||||
for (const minutesBefore of defaultReminders) {
|
||||
for (const minutesBefore of REMINDERS_MIN) {
|
||||
const triggerTime = new Date(leaveByTime.getTime() - minutesBefore * 60 * 1000);
|
||||
|
||||
// Skip if trigger time is in the past
|
||||
if (triggerTime <= new Date()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if this would be before the event actually starts (add some safety margin)
|
||||
if (triggerTime < new Date(event.eventTime.getTime() - 2 * 60 * 60 * 1000)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use timestamp (seconds) as trigger — more reliable than Date object across versions
|
||||
const timestampSeconds = Math.floor(triggerTime.getTime() / 1000);
|
||||
if (triggerTime <= now || triggerTime < twoHoursBefore) continue;
|
||||
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: `🚆 ${event.title}`,
|
||||
title: event.title,
|
||||
body: minutesBefore === 0
|
||||
? 'Zeit zu gehen!'
|
||||
: `${minutesBefore} Minuten bis du losmusst`,
|
||||
data: { eventId: event.id },
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
trigger: timestampSeconds as any,
|
||||
trigger: { type: Notifications.SchedulableTriggerInputTypes.DATE, date: triggerTime },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule (or re-schedule) notifications for a single event.
|
||||
* Cancels any existing notifications for this event first to avoid duplicates.
|
||||
* No-ops if notifications are globally disabled in settings.
|
||||
*/
|
||||
async function scheduleEventNotification(event: Event): Promise<void> {
|
||||
const settings = await getNotificationSettings();
|
||||
if (!settings.enabled) return;
|
||||
|
||||
const leaveByTime = await calculateLeaveByTime(event, settings.arrivalBufferMinutes, settings.bufferMinutes);
|
||||
|
||||
const existing = await Notifications.getAllScheduledNotificationsAsync();
|
||||
for (const notif of existing.filter(n => n.content.data?.eventId === event.id)) {
|
||||
await Notifications.cancelScheduledNotificationAsync(notif.identifier);
|
||||
}
|
||||
|
||||
await fireNotificationsForEvent(event, leaveByTime);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Events
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
@@ -147,7 +165,7 @@ export async function removeEvent(id: string, onDone?: () => void): Promise<void
|
||||
|
||||
export async function loadOriginStation(): Promise<Station | null> {
|
||||
const json = await AsyncStorage.getItem(ORIGIN_KEY);
|
||||
return json ? JSON.parse(json) : null;
|
||||
return json ? JSON.parse(json) : DEFAULT_ORIGIN_STATION;
|
||||
}
|
||||
|
||||
export async function saveOriginStation(station: Station): Promise<void> {
|
||||
@@ -172,52 +190,48 @@ export async function saveNotificationSettings(
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Reschedule all notifications (for origin/setting changes)
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Calendar Selection
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function rescheduleAllNotifications(): Promise<void> {
|
||||
const events = await loadEvents();
|
||||
const settings = await loadNotificationSettings();
|
||||
|
||||
// Cancel ALL existing notifications first
|
||||
await Notifications.cancelAllScheduledNotificationsAsync();
|
||||
|
||||
// Schedule new notifications for each event
|
||||
for (const event of events) {
|
||||
if (settings.enabled) {
|
||||
const leaveByTime = await calculateLeaveByTime(event, settings.arrivalBufferMinutes, settings.bufferMinutes);
|
||||
|
||||
// Default reminders: 30min, 10min, and at leave-by time
|
||||
const defaultReminders = [30, 10, 0];
|
||||
|
||||
for (const minutesBefore of defaultReminders) {
|
||||
const triggerTime = new Date(leaveByTime.getTime() - minutesBefore * 60 * 1000);
|
||||
|
||||
// Skip if trigger time is in the past
|
||||
if (triggerTime <= new Date()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if this would be before the event actually starts (add some safety margin)
|
||||
if (triggerTime < new Date(event.eventTime.getTime() - 2 * 60 * 60 * 1000)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use timestamp (seconds) as trigger — more reliable than Date object
|
||||
const timestampSeconds = Math.floor(triggerTime.getTime() / 1000);
|
||||
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: `🚆 ${event.title}`,
|
||||
body: minutesBefore === 0
|
||||
? 'Zeit zu gehen!'
|
||||
: `${minutesBefore} Minuten bis du losmusst`,
|
||||
data: { eventId: event.id },
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
trigger: timestampSeconds as any,
|
||||
});
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Get the list of calendar IDs the user has chosen to sync.
|
||||
* Returns an empty array if no selection has been made yet (meaning
|
||||
* all calendars should be synced).
|
||||
*/
|
||||
export async function getSelectedCalendarIds(): Promise<string[]> {
|
||||
const json = await AsyncStorage.getItem(SELECTED_CALENDARS_KEY);
|
||||
if (!json) return [];
|
||||
try {
|
||||
return JSON.parse(json) as string[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the list of calendar IDs the user wants to sync.
|
||||
* Pass an empty array to reset to "sync all calendars".
|
||||
*/
|
||||
export async function saveSelectedCalendarIds(ids: string[]): Promise<void> {
|
||||
await AsyncStorage.setItem(SELECTED_CALENDARS_KEY, JSON.stringify(ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the user has made an explicit calendar selection.
|
||||
* Returns true if a non-empty list is stored.
|
||||
*/
|
||||
export async function hasCalendarSelection(): Promise<boolean> {
|
||||
const ids = await getSelectedCalendarIds();
|
||||
return ids.length > 0;
|
||||
}
|
||||
|
||||
export async function rescheduleAllNotifications(): Promise<void> {
|
||||
const [events, settings] = await Promise.all([loadEvents(), loadNotificationSettings()]);
|
||||
await Notifications.cancelAllScheduledNotificationsAsync();
|
||||
if (!settings.enabled) return;
|
||||
|
||||
for (const event of events) {
|
||||
const leaveByTime = await calculateLeaveByTime(event, settings.arrivalBufferMinutes, settings.bufferMinutes);
|
||||
await fireNotificationsForEvent(event, leaveByTime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
export type RootStack = {
|
||||
EventList: undefined;
|
||||
EventDetail: { eventId: string };
|
||||
AddEvent: { editEventId?: string };
|
||||
AddEvent: undefined | { editEventId?: string };
|
||||
Settings: undefined;
|
||||
CalendarImport: undefined;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Decode a Google polyline string into an array of { latitude, longitude } points.
|
||||
*
|
||||
* Based on the polyline algorithm:
|
||||
* https://developers.google.com/maps/documentation/utilities/polylinealgorithm
|
||||
*/
|
||||
export function decodePolyline(encoded: string): Array<{ latitude: number; longitude: number }> {
|
||||
const points: Array<{ latitude: number; longitude: number }> = [];
|
||||
let index = 0;
|
||||
let lat = 0;
|
||||
let lng = 0;
|
||||
|
||||
while (index < encoded.length) {
|
||||
let b;
|
||||
let shift = 0;
|
||||
let result = 0;
|
||||
|
||||
// Decode latitude
|
||||
do {
|
||||
b = encoded.charCodeAt(index++) - 63;
|
||||
result |= (b & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (b >= 0x20);
|
||||
const dlat = (result & 1) !== 0 ? ~(result >> 1) : result >> 1;
|
||||
lat += dlat;
|
||||
|
||||
shift = 0;
|
||||
result = 0;
|
||||
|
||||
// Decode longitude
|
||||
do {
|
||||
b = encoded.charCodeAt(index++) - 63;
|
||||
result |= (b & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (b >= 0x20);
|
||||
const dlng = (result & 1) !== 0 ? ~(result >> 1) : result >> 1;
|
||||
lng += dlng;
|
||||
|
||||
points.push({
|
||||
latitude: lat / 1e5,
|
||||
longitude: lng / 1e5,
|
||||
});
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['next/core-web-vitals'],
|
||||
rules: {
|
||||
'@next/next/no-html-link-for-pages': 'off',
|
||||
},
|
||||
ignorePatterns: ['node_modules/', '.next/', 'out/', 'dist/'],
|
||||
};
|
||||
@@ -7,6 +7,8 @@ const nextConfig: NextConfig = {
|
||||
env: {
|
||||
CORS_ALLOWED_ORIGINS: process.env.CORS_ALLOWED_ORIGINS,
|
||||
DEPLOYMENT_URL: process.env.DEPLOYMENT_URL,
|
||||
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
|
||||
GOOGLE_REDIRECT_URI: process.env.GOOGLE_REDIRECT_URI,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -15,19 +15,21 @@
|
||||
"@timetoleave/api-client": "*",
|
||||
"@timetoleave/core": "*",
|
||||
"date-fns": "^4.1.0",
|
||||
"fflate": "^0.8.2",
|
||||
"next": "^16.2.6",
|
||||
"node-ical": "^0.26.1",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/react": "~19.1.10",
|
||||
"@types/react-dom": "~19.1.10",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.6",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/></svg>
|
||||
|
After Width: | Height: | Size: 528 B |
@@ -0,0 +1,45 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="TimeToLeave">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="280" y1="210" x2="900" y2="720" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#8B5CF6"/>
|
||||
<stop offset="42%" stop-color="#B23CFF"/>
|
||||
<stop offset="72%" stop-color="#D946EF"/>
|
||||
<stop offset="100%" stop-color="#FF2D8D"/>
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-35%" y="-35%" width="170%" height="170%">
|
||||
<feGaussianBlur stdDeviation="8" result="blur"/>
|
||||
<feColorMatrix in="blur" type="matrix"
|
||||
values="0.72 0 0 0 0.26 0 0.18 0 0 0.92 0 0 0.34 0 1 0 0 0 0.32 0"
|
||||
result="glow"/>
|
||||
<feMerge><feMergeNode in="glow"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect width="512" height="512" rx="110" fill="#0A0816"/>
|
||||
|
||||
<g filter="url(#glow)" transform="matrix(0.639 0 0 0.639 -173.4 -69.2)">
|
||||
<path d="M334 552 C324 498 335 437 370 388 C415 324 486 290 562 293 C660 297 744 372 758 471 C763 508 758 541 753 565 C748 589 756 610 779 622 C808 637 810 594 832 590 C855 586 860 620 851 644 C842 669 824 682 803 678"
|
||||
fill="none" stroke="url(#g)" stroke-width="42" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M334 552 C322 588 331 622 365 628 C395 633 386 581 415 580 C447 579 441 641 475 641 C510 641 506 585 544 585 C579 585 571 651 604 670 C650 697 733 695 797 657"
|
||||
fill="none" stroke="url(#g)" stroke-width="42" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M475 641 C472 690 474 724 489 724 C506 724 506 690 509 651"
|
||||
fill="none" stroke="url(#g)" stroke-width="42" stroke-linecap="round"/>
|
||||
<path d="M797 657 C830 639 862 624 898 615"
|
||||
fill="none" stroke="url(#g)" stroke-width="42" stroke-linecap="round"/>
|
||||
<path d="M882 572 L1010 620 L904 708 L917 650 Z" fill="#FF2D8D"/>
|
||||
<ellipse cx="505" cy="725" rx="17" ry="26" fill="#B23CFF"/>
|
||||
|
||||
<g stroke="#F4F1EA" stroke-linecap="round" opacity="0.96">
|
||||
<line x1="556" y1="334" x2="556" y2="360" stroke-width="15"/>
|
||||
<line x1="694" y1="391" x2="716" y2="378" stroke-width="15"/>
|
||||
<line x1="733" y1="526" x2="762" y2="526" stroke-width="15"/>
|
||||
<line x1="405" y1="655" x2="425" y2="633" stroke-width="15"/>
|
||||
<line x1="362" y1="526" x2="390" y2="526" stroke-width="15"/>
|
||||
<line x1="405" y1="398" x2="425" y2="420" stroke-width="15"/>
|
||||
<line x1="658" y1="420" x2="671" y2="398" stroke-width="15"/>
|
||||
<line x1="556" y1="526" x2="556" y2="405" stroke-width="19"/>
|
||||
<line x1="556" y1="526" x2="665" y2="590" stroke-width="19"/>
|
||||
</g>
|
||||
<circle cx="556" cy="526" r="25" fill="#F4F1EA"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,57 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="TimeToLeave">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="280" y1="210" x2="900" y2="720" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#8B5CF6"/>
|
||||
<stop offset="42%" stop-color="#B23CFF"/>
|
||||
<stop offset="72%" stop-color="#D946EF"/>
|
||||
<stop offset="100%" stop-color="#FF2D8D"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="tg" x1="228" y1="368" x2="295" y2="428" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#B23CFF"/>
|
||||
<stop offset="100%" stop-color="#FF2D8D"/>
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-35%" y="-35%" width="170%" height="170%">
|
||||
<feGaussianBlur stdDeviation="8" result="blur"/>
|
||||
<feColorMatrix in="blur" type="matrix"
|
||||
values="0.72 0 0 0 0.26 0 0.18 0 0 0.92 0 0 0.34 0 1 0 0 0 0.32 0"
|
||||
result="glow"/>
|
||||
<feMerge><feMergeNode in="glow"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect width="512" height="512" fill="#0A0816"/>
|
||||
|
||||
<!-- Mark centred in top portion -->
|
||||
<g filter="url(#glow)" transform="matrix(0.579 0 0 0.579 -133.1 -109.7)">
|
||||
<path d="M334 552 C324 498 335 437 370 388 C415 324 486 290 562 293 C660 297 744 372 758 471 C763 508 758 541 753 565 C748 589 756 610 779 622 C808 637 810 594 832 590 C855 586 860 620 851 644 C842 669 824 682 803 678"
|
||||
fill="none" stroke="url(#g)" stroke-width="42" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M334 552 C322 588 331 622 365 628 C395 633 386 581 415 580 C447 579 441 641 475 641 C510 641 506 585 544 585 C579 585 571 651 604 670 C650 697 733 695 797 657"
|
||||
fill="none" stroke="url(#g)" stroke-width="42" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M475 641 C472 690 474 724 489 724 C506 724 506 690 509 651"
|
||||
fill="none" stroke="url(#g)" stroke-width="42" stroke-linecap="round"/>
|
||||
<path d="M797 657 C830 639 862 624 898 615"
|
||||
fill="none" stroke="url(#g)" stroke-width="42" stroke-linecap="round"/>
|
||||
<path d="M882 572 L1010 620 L904 708 L917 650 Z" fill="#FF2D8D"/>
|
||||
<ellipse cx="505" cy="725" rx="17" ry="26" fill="#B23CFF"/>
|
||||
|
||||
<g stroke="#F4F1EA" stroke-linecap="round" opacity="0.96">
|
||||
<line x1="556" y1="334" x2="556" y2="360" stroke-width="15"/>
|
||||
<line x1="694" y1="391" x2="716" y2="378" stroke-width="15"/>
|
||||
<line x1="733" y1="526" x2="762" y2="526" stroke-width="15"/>
|
||||
<line x1="405" y1="655" x2="425" y2="633" stroke-width="15"/>
|
||||
<line x1="362" y1="526" x2="390" y2="526" stroke-width="15"/>
|
||||
<line x1="405" y1="398" x2="425" y2="420" stroke-width="15"/>
|
||||
<line x1="658" y1="420" x2="671" y2="398" stroke-width="15"/>
|
||||
<line x1="556" y1="526" x2="556" y2="405" stroke-width="19"/>
|
||||
<line x1="556" y1="526" x2="665" y2="590" stroke-width="19"/>
|
||||
</g>
|
||||
<circle cx="556" cy="526" r="25" fill="#F4F1EA"/>
|
||||
</g>
|
||||
|
||||
<!-- "TimeToLeave" text centred below mark -->
|
||||
<g font-family="Inter, 'Segoe UI', system-ui, Arial, sans-serif" font-size="64" font-weight="800">
|
||||
<text x="90" y="420" fill="#F4F1EA">Time</text>
|
||||
<text x="228" y="420" fill="url(#tg)">To</text>
|
||||
<text x="294" y="420" fill="#F4F1EA">Leave</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |