Compare commits
31 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 | |||
| 672d053ece | |||
| 316e5def72 |
@@ -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/'],
|
||||
};
|
||||
@@ -1,13 +1,20 @@
|
||||
import { useEffect } from 'react';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import * as Notifications from './src/services/expoNotifications';
|
||||
import AppNavigator from './src/navigation/AppNavigator';
|
||||
|
||||
export default function App() {
|
||||
useEffect(() => {
|
||||
// Request notification permissions on app start
|
||||
Notifications.requestPermissionsAsync();
|
||||
const initRef = useRef(false);
|
||||
|
||||
// Set up notification handler
|
||||
useEffect(() => {
|
||||
// Run initialization only once
|
||||
if (initRef.current) return;
|
||||
initRef.current = true;
|
||||
|
||||
(async () => {
|
||||
// Request notification permissions
|
||||
await Notifications.requestPermissionsAsync();
|
||||
|
||||
// Set up notification handler (called exactly once)
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
@@ -17,6 +24,7 @@ export default function App() {
|
||||
shouldShowList: true,
|
||||
}),
|
||||
});
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return <AppNavigator />;
|
||||
|
||||
@@ -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 |
@@ -1,3 +1,4 @@
|
||||
import './src/polyfills/sharedArrayBuffer';
|
||||
import { registerRootComponent } from 'expo';
|
||||
|
||||
import App from './App';
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
module.exports = {
|
||||
preset: 'jest-expo',
|
||||
testMatch: ['**/__tests__/**/*.test.[jt]s?(x)'],
|
||||
moduleNameMapper: {
|
||||
'^@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/.*)',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getDefaultConfig } from 'expo/metro-config.js';
|
||||
|
||||
const projectRoot = path.dirname(fileURLToPath(import.meta.url));
|
||||
const workspaceRoot = path.resolve(projectRoot, '../..');
|
||||
|
||||
const config = getDefaultConfig(projectRoot);
|
||||
|
||||
config.resolver.disableHierarchicalLookup = true;
|
||||
config.resolver.nodeModulesPaths = [
|
||||
path.resolve(projectRoot, 'node_modules'),
|
||||
path.resolve(workspaceRoot, 'node_modules'),
|
||||
];
|
||||
config.resolver.extraNodeModules = {
|
||||
react: path.resolve(projectRoot, 'node_modules/react'),
|
||||
'react-test-renderer': path.resolve(projectRoot, 'node_modules/react-test-renderer'),
|
||||
'react-native-safe-area-context': path.resolve(projectRoot, 'node_modules/react-native-safe-area-context'),
|
||||
'react-native-screens': path.resolve(projectRoot, 'node_modules/react-native-screens'),
|
||||
'@react-native-async-storage/async-storage': path.resolve(
|
||||
projectRoot,
|
||||
'node_modules/@react-native-async-storage/async-storage',
|
||||
),
|
||||
'expo-application': path.resolve(projectRoot, 'node_modules/expo-notifications/node_modules/expo-application'),
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -5,38 +5,43 @@
|
||||
"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": {
|
||||
"@react-native-async-storage/async-storage": "^3.0.2",
|
||||
"@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-calendar": "^55.0.14",
|
||||
"expo-location": "^55.1.9",
|
||||
"expo-notifications": "^55.0.22",
|
||||
"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.2.4",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-safe-area-context": "^5.7.0",
|
||||
"react-native-screens": "^4.24.0"
|
||||
"react-native-maps": "^1.20.0",
|
||||
"react-native-safe-area-context": "~5.6.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",
|
||||
"react-test-renderer": "19.2.4",
|
||||
"react-test-renderer": "19.1.0",
|
||||
"ts-jest": "^29.4.9",
|
||||
"typescript": "~5.9.2",
|
||||
"typescript-eslint": "^8.59.3"
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
rescheduleAllNotifications
|
||||
} from '../store/eventStore';
|
||||
import { calculateLeaveByTime } from '../services/notifications';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import * as Notifications from '../services/expoNotifications';
|
||||
|
||||
// Mock AsyncStorage
|
||||
jest.mock('@react-native-async-storage/async-storage', () => ({
|
||||
@@ -21,8 +21,8 @@ jest.mock('@react-native-async-storage/async-storage', () => ({
|
||||
removeItem: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock expo-notifications
|
||||
jest.mock('expo-notifications', () => ({
|
||||
// Mock notification adapter
|
||||
jest.mock('../services/expoNotifications', () => ({
|
||||
getAllScheduledNotificationsAsync: jest.fn(),
|
||||
cancelScheduledNotificationAsync: jest.fn(),
|
||||
cancelAllScheduledNotificationsAsync: jest.fn(),
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Tests for notification service
|
||||
// Mock expo-notifications before importing
|
||||
jest.mock('expo-notifications', () => ({
|
||||
// Mock notification adapter before importing
|
||||
jest.mock('../services/expoNotifications', () => ({
|
||||
setNotificationHandler: jest.fn(),
|
||||
requestPermissionsAsync: jest.fn().mockResolvedValue({ status: 'granted' }),
|
||||
scheduleNotificationAsync: jest.fn().mockResolvedValue({ identifier: 'mock-id' }),
|
||||
|
||||
@@ -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
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { Journey } from '@timetoleave/core';
|
||||
|
||||
interface DepartureTimeResult {
|
||||
departureTime: Date | null;
|
||||
arrivalTime: Date | null;
|
||||
mode: 'train' | 'bike' | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
journeys: Journey[] | null,
|
||||
bikeDurationSeconds: number | null,
|
||||
activeMode: 'train' | 'bike' | null,
|
||||
arrivalBufferMinutes: number,
|
||||
trainWalkDurationSeconds = 0,
|
||||
originWalkDurationSeconds = 0,
|
||||
): DepartureTimeResult {
|
||||
return useMemo(() => {
|
||||
// Calculate target arrival time (event time minus buffer)
|
||||
const targetArrivalTime = new Date(eventTime);
|
||||
targetArrivalTime.setMinutes(targetArrivalTime.getMinutes() - arrivalBufferMinutes);
|
||||
|
||||
// Filter out cancelled journeys
|
||||
const validJourneys = journeys?.filter((journey) => !journey.cancelled) || [];
|
||||
|
||||
let departureTime: Date | null = null;
|
||||
let arrivalTime: Date | null = null;
|
||||
let mode: 'train' | 'bike' | null = null;
|
||||
|
||||
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() + walkDurationMs <= targetArrivalTime.getTime(),
|
||||
);
|
||||
|
||||
if (onTimeJourneys.length > 0) {
|
||||
// Pick the journey with the latest departure that still arrives on time
|
||||
const bestJourney = onTimeJourneys.reduce((latest, current) =>
|
||||
current.rD.getTime() > latest.rD.getTime() ? current : latest,
|
||||
);
|
||||
|
||||
departureTime = new Date(bestJourney.rD.getTime() - originWalkDurationSeconds * 1000);
|
||||
arrivalTime = new Date(bestJourney.rA.getTime() + walkDurationMs);
|
||||
mode = 'train';
|
||||
}
|
||||
}
|
||||
|
||||
if (activeMode === 'bike' && bikeDurationSeconds !== null && bikeDurationSeconds > 0) {
|
||||
const bikeDurationMs = bikeDurationSeconds * 1000;
|
||||
const totalBufferMs = arrivalBufferMinutes * 60 * 1000;
|
||||
const targetArrivalMs = eventTime.getTime() - totalBufferMs;
|
||||
|
||||
departureTime = new Date(targetArrivalMs - bikeDurationMs);
|
||||
arrivalTime = new Date(targetArrivalMs);
|
||||
mode = 'bike';
|
||||
}
|
||||
|
||||
return { departureTime, arrivalTime, mode };
|
||||
}, [
|
||||
eventTime,
|
||||
journeys,
|
||||
bikeDurationSeconds,
|
||||
activeMode,
|
||||
arrivalBufferMinutes,
|
||||
trainWalkDurationSeconds,
|
||||
originWalkDurationSeconds,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
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;
|
||||
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) {
|
||||
const [station, setStation] = useState<Station | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!destination?.trim()) {
|
||||
setStation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
// Debounce the lookup
|
||||
const timeoutId = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// First geocode the destination to get coordinates
|
||||
const geocodeResults = await api.geocode(destination, 'at');
|
||||
const coords = geocodeResults[0];
|
||||
|
||||
if (!coords) {
|
||||
if (isMounted) {
|
||||
setStation(null);
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Then use HAFAS LocMatch to find the nearest station
|
||||
const body = {
|
||||
svcReqL: [
|
||||
{
|
||||
meth: 'LocMatch',
|
||||
req: {
|
||||
input: {
|
||||
loc: {
|
||||
crd: {
|
||||
x: Math.round(coords.lng * 1e6),
|
||||
y: Math.round(coords.lat * 1e6),
|
||||
},
|
||||
type: 'S',
|
||||
},
|
||||
maxLoc: 1,
|
||||
field: 'S',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
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: normalizeHafasCoordinate(l.lat ?? l.crd?.y),
|
||||
lng: normalizeHafasCoordinate(l.lon ?? l.crd?.x),
|
||||
}));
|
||||
|
||||
if (!isMounted) return;
|
||||
setStation(stations[0] ?? null);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
if (isMounted) {
|
||||
setError(err instanceof Error ? err.message : 'Station lookup failed');
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, 400);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [destination]);
|
||||
|
||||
return { station, loading, error };
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { GeocodeResult } from '@timetoleave/core';
|
||||
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) {
|
||||
const [coords, setCoords] = useState<GeocodeResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!destination?.trim()) {
|
||||
setCoords(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const timeoutId = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const results = await api.geocode(destination, 'at');
|
||||
if (isMounted) {
|
||||
setCoords(results[0] ?? null);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMounted) {
|
||||
setError(err instanceof Error ? err.message : 'Geocoding failed');
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, 400);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [destination]);
|
||||
|
||||
return { coords, loading, error };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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
|
||||
// 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() {
|
||||
const [dark, setDark] = useState(false);
|
||||
const initialized = useRef(false);
|
||||
|
||||
// Load theme on mount
|
||||
useEffect(() => {
|
||||
if (initialized.current) return;
|
||||
initialized.current = true;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(THEME_KEY);
|
||||
if (stored === 'dark' || stored === 'light') {
|
||||
setDark(stored === 'dark');
|
||||
} else {
|
||||
setDark(getDefaultTheme() === 'dark');
|
||||
}
|
||||
} catch {
|
||||
setDark(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setDark((prev) => {
|
||||
const next = !prev;
|
||||
AsyncStorage.setItem(THEME_KEY, next ? 'dark' : 'light').catch(() => {
|
||||
// Silently fail storage
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { dark, toggle };
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { WalkRoute } from '@timetoleave/core';
|
||||
import { api } from '../services/api';
|
||||
|
||||
/**
|
||||
* 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(
|
||||
fromLat: number | undefined,
|
||||
fromLng: number | undefined,
|
||||
toLat: number | undefined,
|
||||
toLng: number | undefined,
|
||||
) {
|
||||
const [walkRoute, setWalkRoute] = useState<WalkRoute | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const fetchRoute = async () => {
|
||||
if (fromLat == null || fromLng == null || toLat == null || toLng == null) {
|
||||
setWalkRoute(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await api.getWalkRoute(fromLat, fromLng, toLat, toLng);
|
||||
|
||||
if (isMounted) {
|
||||
setWalkRoute(data);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (isMounted) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to fetch walk route';
|
||||
setError(message);
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchRoute();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [fromLat, fromLng, toLat, toLng]);
|
||||
|
||||
return { walkRoute, loading, error };
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import type { WienerLinienStop, WienerLinienDeparture } from '@timetoleave/core';
|
||||
import { api } from '../services/api';
|
||||
|
||||
/** Flattened departure row shown in the NearbyStops UI. */
|
||||
export interface DepartureRow {
|
||||
stopId: string;
|
||||
lineName: string;
|
||||
direction: string;
|
||||
minutes: number;
|
||||
}
|
||||
|
||||
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 {
|
||||
stopId: dep.stopId,
|
||||
lineName: dep.line.name,
|
||||
direction: dep.direction,
|
||||
minutes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
lng: number | undefined,
|
||||
radius?: number,
|
||||
) {
|
||||
const [stops, setStops] = useState<WienerLinienStop[]>([]);
|
||||
const [departures, setDepartures] = useState<DepartureRow[]>([]);
|
||||
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 cancelledRef = useRef(false);
|
||||
|
||||
/** 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;
|
||||
|
||||
if (lat === undefined || lng === undefined) {
|
||||
setStops([]);
|
||||
setDepartures([]);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const debounceTimer = setTimeout(async () => {
|
||||
if (cancelledRef.current) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const stopsList = await api.findNearbyStops(lat, lng, radius ?? 500);
|
||||
|
||||
if (cancelledRef.current) return;
|
||||
|
||||
setStops(stopsList);
|
||||
setLoading(false);
|
||||
|
||||
const ids = stopsList.map((s) => s.id);
|
||||
stopIdsRef.current = ids;
|
||||
|
||||
await fetchMonitor(ids);
|
||||
} catch (err) {
|
||||
if (cancelledRef.current) return;
|
||||
setError(err instanceof Error ? err.message : 'Stops could not be loaded');
|
||||
setLoading(false);
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
clearTimeout(debounceTimer);
|
||||
};
|
||||
}, [lat, lng, radius, fetchMonitor]);
|
||||
|
||||
// Periodic departures refresh
|
||||
useEffect(() => {
|
||||
if (stops.length === 0) return;
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
const ids = stopIdsRef.current;
|
||||
if (ids.length > 0) {
|
||||
fetchMonitor(ids);
|
||||
}
|
||||
}, REFRESH_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [stops.length, fetchMonitor]);
|
||||
|
||||
return { stops, departures, loading, error };
|
||||
}
|
||||
@@ -1,41 +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 ────────────────────────────────────────
|
||||
// ── Root Stack ──
|
||||
|
||||
const RootStack = createNativeStackNavigator<{
|
||||
EventList: undefined;
|
||||
EventDetail: { eventId: string };
|
||||
AddEvent: undefined;
|
||||
Settings: undefined;
|
||||
CalendarImport: undefined;
|
||||
}>();
|
||||
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>
|
||||
<RootStack.Navigator
|
||||
<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>
|
||||
),
|
||||
})}
|
||||
>
|
||||
<RootStack.Screen name="EventList" component={EventListScreen} options={{ title: 'Time To Leave' }} />
|
||||
<RootStack.Screen name="AddEvent" component={AddEventScreen} options={{ title: 'Add Event' }} />
|
||||
<RootStack.Screen name="EventDetail" component={EventDetailScreen} options={{ title: 'Event Details' }} />
|
||||
<RootStack.Screen name="Settings" component={SettingsScreen} options={{ title: 'Settings' }} />
|
||||
<RootStack.Screen name="CalendarImport" component={CalendarImportScreen} options={{ title: 'Import Calendar' }} />
|
||||
</RootStack.Navigator>
|
||||
<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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 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;
|
||||
toWellFormed?: () => string;
|
||||
};
|
||||
|
||||
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 = '';
|
||||
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index);
|
||||
|
||||
if (code >= 0xd800 && code <= 0xdbff) {
|
||||
const next = value.charCodeAt(index + 1);
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
result += value[index] + value[index + 1];
|
||||
index += 1;
|
||||
} else {
|
||||
result += '\uFFFD';
|
||||
}
|
||||
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
||||
result += '\uFFFD';
|
||||
} else {
|
||||
result += value[index];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
if (typeof stringPrototype.toWellFormed !== 'function') {
|
||||
Object.defineProperty(String.prototype, 'toWellFormed', {
|
||||
configurable: true,
|
||||
value() {
|
||||
return toWellFormedString(String(this));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof stringPrototype.isWellFormed !== 'function') {
|
||||
Object.defineProperty(String.prototype, 'isWellFormed', {
|
||||
configurable: true,
|
||||
value() {
|
||||
const value = String(this);
|
||||
return toWellFormedString(value) === value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'resizable')) {
|
||||
Object.defineProperty(ArrayBuffer.prototype, 'resizable', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof globalScope.SharedArrayBuffer === 'undefined') {
|
||||
class SharedArrayBufferPolyfill extends ArrayBuffer {
|
||||
get byteLength() {
|
||||
return arrayBufferByteLength?.call(this) ?? 0;
|
||||
}
|
||||
|
||||
get growable() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(SharedArrayBufferPolyfill.prototype, Symbol.toStringTag, {
|
||||
configurable: true,
|
||||
value: 'SharedArrayBuffer',
|
||||
});
|
||||
|
||||
globalScope.SharedArrayBuffer = SharedArrayBufferPolyfill;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
Text,
|
||||
@@ -6,38 +6,60 @@ 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 { addEvent } from '../store/eventStore';
|
||||
import { loadEvents, addEvent, updateEvent } from '../store/eventStore';
|
||||
import type { Event as CalendarEvent } from '@timetoleave/core';
|
||||
|
||||
type RootStack = {
|
||||
EventList: undefined;
|
||||
EventDetail: { eventId: string };
|
||||
AddEvent: undefined;
|
||||
Settings: undefined;
|
||||
CalendarImport: undefined;
|
||||
};
|
||||
import type { RootStack } from '../types/navigation';
|
||||
import { useColors } from '../hooks/useColors';
|
||||
|
||||
type ScreenProps = {
|
||||
navigation: NativeStackNavigationProp<RootStack, 'AddEvent'>;
|
||||
route: RouteProp<RootStack, 'AddEvent'>;
|
||||
};
|
||||
|
||||
export function AddEventScreen({ navigation }: ScreenProps) {
|
||||
/**
|
||||
* 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 colors = useColors();
|
||||
const [title, setTitle] = useState('');
|
||||
const [destination, setDestination] = useState('');
|
||||
const [dateStr, setDateStr] = useState('');
|
||||
const [timeStr, setTimeStr] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
// If editing an existing event, populate the form
|
||||
useEffect(() => {
|
||||
if (!route.params?.editEventId) return;
|
||||
|
||||
(async () => {
|
||||
const events = await loadEvents();
|
||||
const event = events.find((e) => e.id === route.params?.editEventId);
|
||||
if (event) {
|
||||
setTitle(event.title);
|
||||
setDestination(event.destination);
|
||||
const d = new Date(event.eventTime);
|
||||
setDateStr(d.toISOString().split('T')[0]);
|
||||
setTimeStr(d.toTimeString().slice(0, 5));
|
||||
}
|
||||
})();
|
||||
}, [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;
|
||||
};
|
||||
@@ -46,6 +68,16 @@ export function AddEventScreen({ navigation }: ScreenProps) {
|
||||
if (!validate()) return;
|
||||
|
||||
const eventTime = new Date(`${dateStr}T${timeStr}`);
|
||||
|
||||
if (route.params?.editEventId) {
|
||||
// Update existing event
|
||||
await updateEvent(route.params.editEventId, {
|
||||
title: title.trim(),
|
||||
destination: destination.trim(),
|
||||
eventTime,
|
||||
});
|
||||
} else {
|
||||
// Create new event
|
||||
const event: CalendarEvent = {
|
||||
id: `evt_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
title: title.trim(),
|
||||
@@ -55,88 +87,131 @@ export function AddEventScreen({ navigation }: ScreenProps) {
|
||||
};
|
||||
|
||||
await addEvent(event);
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
navigation.goBack();
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.container, { backgroundColor: colors.background, position: 'relative' }]}>
|
||||
<View style={styles.form}>
|
||||
<Text style={styles.label}>Titel</Text>
|
||||
<Text style={[styles.label, { color: colors.text }]}>Title</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="z.B. Team Meeting"
|
||||
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
|
||||
placeholder="e.g. Team Meeting"
|
||||
placeholderTextColor={colors.subtext}
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
autoCapitalize="words"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Ziel</Text>
|
||||
<Text style={[styles.label, { color: colors.text }]}>Destination</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="z.B. Wien, Donau-City"
|
||||
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
|
||||
placeholder="e.g. Technikum Wien"
|
||||
placeholderTextColor={colors.subtext}
|
||||
value={destination}
|
||||
onChangeText={setDestination}
|
||||
autoCapitalize="words"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Datum</Text>
|
||||
<Text style={[styles.label, { color: colors.text }]}>Date</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="JJJJ-MM-TT"
|
||||
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
|
||||
placeholder="YYYY-MM-DD"
|
||||
placeholderTextColor={colors.subtext}
|
||||
value={dateStr}
|
||||
onChangeText={setDateStr}
|
||||
keyboardType="numbers-and-punctuation"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Zeit</Text>
|
||||
<Text style={[styles.label, { color: colors.text }]}>Time</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="SS:MM"
|
||||
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
|
||||
placeholder="HH:MM"
|
||||
placeholderTextColor={colors.subtext}
|
||||
value={timeStr}
|
||||
onChangeText={setTimeStr}
|
||||
keyboardType="numbers-and-punctuation"
|
||||
/>
|
||||
|
||||
{error ? <Text style={styles.errorText}>{error}</Text> : null}
|
||||
{error ? <Text style={[styles.errorText, { color: colors.error }]}>{error}</Text> : null}
|
||||
|
||||
<TouchableOpacity style={styles.saveBtn} onPress={handleSave}>
|
||||
<Text style={styles.saveBtnText}>Speichern</Text>
|
||||
<Text style={styles.saveBtnText}>{route.params?.editEventId ? 'Update' : 'Save'}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.saveBtn, styles.cancelBtn]}
|
||||
style={[styles.saveBtn, styles.cancelBtn, { backgroundColor: colors.border }]}
|
||||
onPress={() => navigation.goBack()}
|
||||
>
|
||||
<Text style={[styles.saveBtnText, styles.cancelText]}>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>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#f2f2f7' },
|
||||
container: { flex: 1 },
|
||||
form: { padding: 20 },
|
||||
label: { fontSize: 14, fontWeight: '600', color: '#1c1c1e', marginBottom: 6 },
|
||||
label: { fontSize: 14, fontWeight: '600', marginBottom: 6 },
|
||||
input: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
marginBottom: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5ea',
|
||||
},
|
||||
errorText: { color: '#FF3B30', fontSize: 14, marginBottom: 8 },
|
||||
errorText: { fontSize: 14, marginBottom: 8 },
|
||||
saveBtn: {
|
||||
backgroundColor: '#007AFF',
|
||||
backgroundColor: '#8B5CF6',
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
saveBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
|
||||
cancelBtn: { marginTop: 12, backgroundColor: '#e5e5ea' },
|
||||
cancelText: { color: '#1c1c1e' },
|
||||
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,41 +1,167 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
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';
|
||||
|
||||
type RootStack = {
|
||||
EventList: undefined;
|
||||
EventDetail: { eventId: string };
|
||||
AddEvent: undefined;
|
||||
Settings: undefined;
|
||||
CalendarImport: undefined;
|
||||
};
|
||||
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 { 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 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);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -44,9 +170,14 @@ 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) {
|
||||
if (!existingIds.has(evt.id)) {
|
||||
const localEvent: CalendarEvent = {
|
||||
id: evt.id,
|
||||
title: evt.title,
|
||||
@@ -55,10 +186,12 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
|
||||
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);
|
||||
}
|
||||
@@ -70,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();
|
||||
@@ -90,26 +231,31 @@ 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 (
|
||||
<View style={styles.container}>
|
||||
<ScrollView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.heading}>Kalender-Import</Text>
|
||||
<Text style={styles.description}>
|
||||
Importiere Termine über eine ICS-URL oder sync mit dem Geräte-Kalender.
|
||||
<Text style={[styles.heading, { color: colors.text }]}>Calendar Import</Text>
|
||||
<Text style={[styles.description, { color: colors.subtext }]}>
|
||||
Import events via an ICS URL or sync with the device calendar.
|
||||
</Text>
|
||||
|
||||
{/* ── ICS URL Import ── */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>ICS-URL Import</Text>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>ICS URL Import</Text>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
|
||||
placeholder="https://calendar.google.com/calendar/ical/..."
|
||||
placeholderTextColor={colors.subtext}
|
||||
value={url}
|
||||
onChangeText={setUrl}
|
||||
autoCapitalize="none"
|
||||
@@ -124,37 +270,113 @@ 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}>Geräte-Kalender Sync</Text>
|
||||
<Text style={styles.sectionDesc}>
|
||||
Hole Termine der nächsten 30 Tage aus den kalendern auf deinem Gerät.
|
||||
<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 }]}>
|
||||
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
|
||||
style={[styles.importBtn, styles.nativeBtn, loading && styles.importBtnDisabled]}
|
||||
style={[styles.importBtn, { backgroundColor: colors.purple }, loading && styles.importBtnDisabled]}
|
||||
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>
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorBanner}>
|
||||
<View style={[styles.errorBanner, { backgroundColor: colors.error }]}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{count !== null && (
|
||||
<View style={styles.successBanner}>
|
||||
<Text style={styles.successText}>
|
||||
✓ {count} Termin(e) erfolgreich importiert!
|
||||
</Text>
|
||||
<View style={[styles.successBanner, { backgroundColor: colors.success }]}>
|
||||
<View style={styles.bannerContent}>
|
||||
<FontAwesomeIcon icon={faCheck} size={13} color="#fff" />
|
||||
<Text style={styles.successText}>{count} event(s) successfully imported!</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -162,49 +384,165 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
|
||||
style={styles.backBtn}
|
||||
onPress={() => navigation.goBack()}
|
||||
>
|
||||
<Text style={styles.backBtnText}>← 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>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#f2f2f7' },
|
||||
container: { flex: 1 },
|
||||
content: { padding: 20 },
|
||||
heading: { fontSize: 22, fontWeight: '700', color: '#1c1c1e', marginBottom: 4 },
|
||||
description: { fontSize: 14, color: '#8e8e93', marginBottom: 20, lineHeight: 20 },
|
||||
heading: { fontSize: 22, fontWeight: '700', marginBottom: 4 },
|
||||
description: { fontSize: 14, marginBottom: 20, lineHeight: 20 },
|
||||
section: { marginBottom: 24 },
|
||||
sectionTitle: { fontSize: 16, fontWeight: '600', color: '#1c1c1e', marginBottom: 8 },
|
||||
sectionDesc: { fontSize: 13, color: '#8e8e93', marginBottom: 12, lineHeight: 18 },
|
||||
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: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5ea',
|
||||
marginBottom: 12,
|
||||
},
|
||||
errorBanner: { backgroundColor: '#FF3B30', borderRadius: 8, padding: 12, marginBottom: 12 },
|
||||
errorBanner: { borderRadius: 8, padding: 12, marginBottom: 12 },
|
||||
errorText: { color: '#fff', fontSize: 14 },
|
||||
successBanner: { backgroundColor: '#34C759', borderRadius: 8, padding: 12, marginBottom: 12 },
|
||||
successBanner: { borderRadius: 8, padding: 12, marginBottom: 12 },
|
||||
successText: { color: '#fff', fontSize: 14 },
|
||||
importBtn: {
|
||||
backgroundColor: '#007AFF',
|
||||
backgroundColor: '#8B5CF6',
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
nativeBtn: {
|
||||
backgroundColor: '#5856D6',
|
||||
},
|
||||
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',
|
||||
},
|
||||
backBtnText: { color: '#007AFF', fontSize: 15 },
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,277 +1,378 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
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 } from '../store/eventStore';
|
||||
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 } from '@timetoleave/core';
|
||||
|
||||
type RootStack = {
|
||||
EventList: undefined;
|
||||
EventDetail: { eventId: string };
|
||||
AddEvent: undefined;
|
||||
Settings: undefined;
|
||||
CalendarImport: undefined;
|
||||
};
|
||||
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 { 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 = {
|
||||
navigation: NativeStackNavigationProp<RootStack, 'EventDetail'>;
|
||||
route: RouteProp<RootStack, 'EventDetail'>;
|
||||
};
|
||||
|
||||
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 colors = useColors();
|
||||
|
||||
const [event, setEvent] = useState<CalendarEvent | null>(null);
|
||||
const [journeys, setJourneys] = useState<Journey[]>([]);
|
||||
const [bikeRoute, setBikeRoute] = useState<BikeRoute | null>(null);
|
||||
const [walkRoute, setWalkRoute] = useState<WalkRoute | null>(null);
|
||||
const [origin, setOrigin] = useState<Station | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingBike, setLoadingBike] = useState(false);
|
||||
const [loadingWalk, setLoadingWalk] = useState(false);
|
||||
const [activeMode, setActiveMode] = useState<TransportMode>('train');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [arrivalBufferMinutes, setArrivalBufferMinutes] = useState(5);
|
||||
const [showBikeOption, setShowBikeOption] = useState(true);
|
||||
const [showWalkingOption, setShowWalkingOption] = useState(true);
|
||||
|
||||
const destStation = useDestinationStation(event?.destination);
|
||||
const destCoords = useGeocode(event?.destination);
|
||||
const walkHook = useWalkRoute(
|
||||
destStation.station?.lat,
|
||||
destStation.station?.lng,
|
||||
destCoords.coords?.lat,
|
||||
destCoords.coords?.lng,
|
||||
);
|
||||
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] = await Promise.all([loadEvents(), loadOriginStation()]);
|
||||
const [events, originStation, settings] = await Promise.all([
|
||||
loadEvents(),
|
||||
loadOriginStation(),
|
||||
loadNotificationSettings(),
|
||||
]);
|
||||
setOrigin(originStation);
|
||||
setArrivalBufferMinutes(settings.arrivalBufferMinutes);
|
||||
setShowBikeOption(settings.showBikeOption);
|
||||
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) {
|
||||
const results = await api.searchJourneys(
|
||||
originStation.extId,
|
||||
found.destination,
|
||||
found.eventTime,
|
||||
);
|
||||
setJourneys(results);
|
||||
const destExtId = destStation.station?.extId;
|
||||
if (destExtId) {
|
||||
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(`Destination station not resolvable: ${destStation.error}`);
|
||||
}
|
||||
|
||||
// Fetch bike route if we have a destination station
|
||||
// We need destination coordinates; for MVP we geocode the destination name
|
||||
try {
|
||||
setLoadingBike(true);
|
||||
const geo = await api.geocode(found.destination);
|
||||
if (geo.length > 0 && originStation.lat && originStation.lng) {
|
||||
if (destCoords.coords && originStation.lat && originStation.lng) {
|
||||
const bike = await api.getBikeRoute(
|
||||
originStation.lat,
|
||||
originStation.lng,
|
||||
geo[0].lat,
|
||||
geo[0].lng,
|
||||
originStation.lat, originStation.lng,
|
||||
destCoords.coords.lat, destCoords.coords.lng,
|
||||
);
|
||||
setBikeRoute(bike);
|
||||
await setCachedBikeRoute(eventId, bike);
|
||||
}
|
||||
} catch {
|
||||
// Bike route is optional — don't fail the whole screen
|
||||
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]);
|
||||
}, [eventId, destStation.station, destStation.error, destCoords.coords, walkHook.walkRoute, walkHook.error]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
setWalkRoute(walkHook.walkRoute);
|
||||
setLoadingWalk(walkHook.loading);
|
||||
if (walkHook.walkRoute) {
|
||||
setCachedWalkRoute(eventId, walkHook.walkRoute).catch(() => {});
|
||||
}
|
||||
}, [walkHook.walkRoute, walkHook.loading, eventId]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setBikeRoute(null);
|
||||
setWalkRoute(null);
|
||||
setJourneys([]);
|
||||
fetchData();
|
||||
};
|
||||
|
||||
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,
|
||||
arrivalBufferMinutes,
|
||||
showWalkingOption ? (walkRoute?.duration ?? 0) : 0,
|
||||
originWalk.walkRoute?.duration ?? 0,
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
<Text style={styles.loadingText}>Termine werden geladen…</Text>
|
||||
<View style={[styles.center, { backgroundColor: colors.background }]}>
|
||||
<ActivityIndicator size="large" color={colors.accent} />
|
||||
<Text style={[styles.loadingText, { color: colors.subtext }]}>
|
||||
{destStation.loading && !event ? 'Resolving destination station…' : 'Loading events…'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const bikeDisabled = !showBikeOption;
|
||||
const effectiveMode: TransportMode =
|
||||
bikeDisabled && activeMode === 'bike' ? 'train' : activeMode;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Event header */}
|
||||
<ScrollView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
{event && (
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.eventTitle}>{event.title}</Text>
|
||||
<Text style={styles.eventDest}>{event.destination}</Text>
|
||||
<Text style={styles.eventTime}>
|
||||
{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}>Quelle: {event.source}</Text>
|
||||
</View>
|
||||
<EventHeader
|
||||
event={event}
|
||||
leaveByTime={departureInfo.departureTime}
|
||||
arrivalBufferMinutes={arrivalBufferMinutes}
|
||||
colors={colors}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<View style={styles.errorBanner}>
|
||||
<Text style={styles.errorBannerText}>⚠ {error}</Text>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Origin status */}
|
||||
{!origin && !error && (
|
||||
<View style={styles.warningBanner}>
|
||||
<Text style={styles.warningBannerText}>
|
||||
Keine Ursprungstation festgelegt.
|
||||
{' '}
|
||||
<Text style={styles.warningLink} onPress={() => navigation.navigate('Settings')}>
|
||||
Einstellungen öffnen
|
||||
<View style={[styles.warningBanner, { backgroundColor: colors.warning }]}>
|
||||
<Text style={styles.bannerText}>
|
||||
No origin station set.{' '}
|
||||
<Text style={styles.bannerLink} onPress={() => navigation.navigate('Settings')}>
|
||||
Open settings
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Journeys list */}
|
||||
<View style={styles.journeys}>
|
||||
<Text style={styles.sectionTitle}>Zugverbindungen</Text>
|
||||
{journeys.length === 0 ? (
|
||||
<Text style={styles.emptyText}>
|
||||
{origin ? 'Keine Verbindungen gefunden' : 'Ursprungstation festlegen'}
|
||||
</Text>
|
||||
) : (
|
||||
journeys.map((j) => (
|
||||
<View key={j.id} style={styles.journeyCard}>
|
||||
<View style={styles.journeyRow}>
|
||||
<Text style={styles.lineText}>
|
||||
{j.trains.length > 0 ? j.trains.join(', ') : '—'}
|
||||
</Text>
|
||||
{j.delay > 0 && <Text style={styles.delayBadge}>+{j.delay} min</Text>}
|
||||
{j.cancelled && <Text style={styles.cancelBadge}>Storniert</Text>}
|
||||
{origin && (
|
||||
<View style={[styles.modeSelector, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.modeButton,
|
||||
effectiveMode === 'train' && { backgroundColor: colors.accent + '22', borderColor: colors.accent },
|
||||
]}
|
||||
onPress={() => setActiveMode('train')}
|
||||
>
|
||||
<View style={styles.modeHeader}>
|
||||
<View style={styles.modeLabelRow}>
|
||||
<FontAwesomeIcon icon={faTrain} size={14} color={colors.text} />
|
||||
<Text style={[styles.modeLabel, { color: colors.text }]}>Train</Text>
|
||||
</View>
|
||||
<Text style={styles.departure}>
|
||||
Abfahrt: {new Date(j.sD).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
|
||||
{' '}
|
||||
(Plattform {j.platform || '—'})
|
||||
</Text>
|
||||
<Text style={styles.arrival}>
|
||||
Ankunft: {new Date(j.sA).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
|
||||
{' '}
|
||||
({j.changes === 0 ? 'Direkt' : `${j.changes} Umst.`})
|
||||
</Text>
|
||||
{effectiveMode === 'train' && (
|
||||
<View style={[styles.activeBadge, { backgroundColor: colors.accent }]}>
|
||||
<Text style={styles.activeBadgeText}>Active</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Bike route section */}
|
||||
<View style={styles.journeys}>
|
||||
<Text style={styles.sectionTitle}>Radroute</Text>
|
||||
{loadingBike ? (
|
||||
<View style={styles.centerBike}>
|
||||
<ActivityIndicator size="small" color="#007AFF" />
|
||||
<Text style={styles.loadingText}>Radroute wird geladen…</Text>
|
||||
</View>
|
||||
) : bikeRoute ? (
|
||||
<View style={styles.bikeCard}>
|
||||
<View style={styles.bikeRow}>
|
||||
<Text style={styles.bikeLabel}>⏱ Dauer</Text>
|
||||
<Text style={styles.bikeValue}>{formatDuration(bikeRoute.duration)}</Text>
|
||||
</View>
|
||||
<View style={styles.bikeRow}>
|
||||
<Text style={styles.bikeLabel}>📏 Distanz</Text>
|
||||
<Text style={styles.bikeValue}>{formatDistance(bikeRoute.distance)}</Text>
|
||||
</View>
|
||||
<View style={styles.mapPlaceholder}>
|
||||
<Text style={styles.mapPlaceholderText}>🗺 Karte (post-MVP)</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.emptyText}>
|
||||
{origin ? 'Keine Radroute verfügbar' : 'Ursprungstation festlegen'}
|
||||
<Text style={[styles.modeMeta, { color: colors.subtext }]}>
|
||||
{showWalkingOption ? 'Train + final walk' : 'Train only'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.modeButton,
|
||||
effectiveMode === 'bike' && { backgroundColor: colors.accent + '22', borderColor: colors.accent },
|
||||
bikeDisabled && { opacity: 0.45 },
|
||||
]}
|
||||
onPress={() => !bikeDisabled && setActiveMode('bike')}
|
||||
disabled={bikeDisabled}
|
||||
>
|
||||
<View style={styles.modeHeader}>
|
||||
<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}>Active</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Refresh */}
|
||||
<TouchableOpacity style={styles.refreshBtn} onPress={handleRefresh}>
|
||||
<Text style={styles.refreshBtnText}>🔄 Neu laden</Text>
|
||||
<Text style={[styles.modeMeta, { color: colors.subtext }]}>
|
||||
{bikeDisabled
|
||||
? 'Disabled in settings'
|
||||
: loadingBike
|
||||
? 'Calculating route...'
|
||||
: 'Direct route'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</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}
|
||||
/>
|
||||
)}
|
||||
|
||||
{effectiveMode === 'bike' && (
|
||||
<BikeSection
|
||||
bikeRoute={bikeRoute}
|
||||
loading={loadingBike}
|
||||
origin={origin}
|
||||
colors={colors}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NearbyStops
|
||||
stops={wienerLinien.stops}
|
||||
departures={wienerLinien.departures}
|
||||
loading={wienerLinien.loading}
|
||||
error={wienerLinien.error}
|
||||
colors={colors}
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<View style={{ height: 40 }} />
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#f2f2f7' },
|
||||
center: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#f2f2f7' },
|
||||
loadingText: { color: '#8e8e93', marginTop: 12, fontSize: 15 },
|
||||
header: { padding: 20, backgroundColor: '#fff', marginBottom: 12 },
|
||||
eventTitle: { fontSize: 22, fontWeight: '700', color: '#1c1c1e' },
|
||||
eventDest: { fontSize: 16, color: '#8e8e93', marginTop: 4 },
|
||||
eventTime: { fontSize: 14, color: '#007AFF', marginTop: 8 },
|
||||
source: { fontSize: 12, color: '#8e8e93', marginTop: 4 },
|
||||
errorBanner: { backgroundColor: '#FF3B30', padding: 12, marginBottom: 12 },
|
||||
errorBannerText: { color: '#fff', fontSize: 14 },
|
||||
warningBanner: { backgroundColor: '#FF9500', padding: 12, marginBottom: 12 },
|
||||
warningBannerText: { color: '#fff', fontSize: 14 },
|
||||
warningLink: { color: '#fff', fontWeight: '700', textDecorationLine: 'underline' },
|
||||
journeys: { padding: 20 },
|
||||
sectionTitle: { fontSize: 18, fontWeight: '600', color: '#1c1c1e', marginBottom: 12 },
|
||||
emptyText: { color: '#8e8e93', fontSize: 14 },
|
||||
journeyCard: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
marginBottom: 10,
|
||||
container: { flex: 1 },
|
||||
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||
loadingText: { marginTop: 12, fontSize: 15 },
|
||||
errorBanner: { padding: 12, marginBottom: 12 },
|
||||
warningBanner: { padding: 12, marginBottom: 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,
|
||||
borderColor: '#e5e5ea',
|
||||
},
|
||||
journeyRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||
lineText: { fontSize: 16, fontWeight: '600', color: '#1c1c1e' },
|
||||
delayBadge: { backgroundColor: '#FF3B30', color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
|
||||
cancelBadge: { backgroundColor: '#000', color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
|
||||
departure: { fontSize: 13, color: '#1c1c1e', marginTop: 6 },
|
||||
arrival: { fontSize: 13, color: '#8e8e93', marginTop: 2 },
|
||||
centerBike: { alignItems: 'center', gap: 8 },
|
||||
bikeCard: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5ea',
|
||||
},
|
||||
bikeRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 6 },
|
||||
bikeLabel: { fontSize: 15, color: '#1c1c1e', fontWeight: '500' },
|
||||
bikeValue: { fontSize: 15, color: '#007AFF', fontWeight: '600' },
|
||||
mapPlaceholder: {
|
||||
marginTop: 10,
|
||||
height: 100,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#f2f2f7',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: '#c7c7cc',
|
||||
},
|
||||
mapPlaceholderText: { fontSize: 14, color: '#8e8e93' },
|
||||
refreshBtn: {
|
||||
alignSelf: 'center',
|
||||
marginTop: 20,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 24,
|
||||
backgroundColor: '#e5e5ea',
|
||||
borderRadius: 12,
|
||||
},
|
||||
refreshBtnText: { fontSize: 15, color: '#1c1c1e', fontWeight: '600' },
|
||||
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' },
|
||||
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,42 +7,66 @@ 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';
|
||||
|
||||
// Color map for countdown urgency
|
||||
const urgencyColor = (urgent: boolean): string => {
|
||||
if (urgent) return '#FF3B30';
|
||||
return '#34C759';
|
||||
};
|
||||
|
||||
type RootStack = {
|
||||
EventList: undefined;
|
||||
EventDetail: { eventId: string };
|
||||
AddEvent: undefined;
|
||||
Settings: undefined;
|
||||
CalendarImport: undefined;
|
||||
};
|
||||
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 { 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 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 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]);
|
||||
|
||||
// Recalculate countdowns every 30 seconds
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setTick(t => t + 1), 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => { reload(); }, [reload]),
|
||||
);
|
||||
@@ -53,81 +77,189 @@ export function EventListScreen({ navigation }: ScreenProps) {
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: CalendarEvent }) => {
|
||||
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}>
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.navigate('EventDetail', { eventId: item.id })}
|
||||
activeOpacity={0.6}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<View style={styles.card}>
|
||||
<View style={[styles.card, { backgroundColor: colors.card }]}>
|
||||
<View style={styles.dotRow}>
|
||||
<View style={[styles.dot, { backgroundColor: urgencyColor(countdown.urgent) }]} />
|
||||
<Text style={styles.title}>{item.title}</Text>
|
||||
<Text style={[styles.badge, { color: countdown.color === 'red' || countdown.color === 'orange' ? '#FF3B30' : '#007AFF' }]}>
|
||||
{countdown.label}
|
||||
</Text>
|
||||
<View style={[styles.dot, { backgroundColor: leaveCountdown?.urgent ? colors.delete : '#34C759' }]} />
|
||||
<Text style={[styles.title, { color: colors.text }]}>{item.title}</Text>
|
||||
</View>
|
||||
<Text style={styles.subtitle}>{item.destination}</Text>
|
||||
<Text style={styles.time}>
|
||||
{item.eventTime.toLocaleString('de-AT', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
<Text style={[styles.subtitle, { color: colors.subtext }]}>{item.destination}</Text>
|
||||
<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}>{status}</Text>
|
||||
<TouchableOpacity onPress={() => removeEvent(item.id, reload)} style={styles.deleteBtn}>
|
||||
<Text style={styles.deleteText}>Entfernen</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>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
if (events.length === 0) {
|
||||
if (!upcomingEvent) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.empty}>Keine Termine</Text>
|
||||
<View style={[styles.center, { backgroundColor: colors.background }]}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.topBar}>
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.navigate('CalendarImport')}
|
||||
style={styles.topBtn}
|
||||
>
|
||||
<Text style={styles.topBtnText}>📅 Kalender</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.navigate('Settings')}
|
||||
style={styles.topBtn}
|
||||
>
|
||||
<Text style={styles.topBtnText}>⚙️ Einstellungen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<FlatList
|
||||
data={events}
|
||||
data={[upcomingEvent]}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={styles.list}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor="#007AFF" />
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.accent} />
|
||||
}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
@@ -141,16 +273,12 @@ export function EventListScreen({ navigation }: ScreenProps) {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#f2f2f7' },
|
||||
topBar: { flexDirection: 'row', justifyContent: 'flex-end', padding: 8, gap: 8 },
|
||||
topBtn: { paddingHorizontal: 12, paddingVertical: 6 },
|
||||
topBtnText: { color: '#007AFF', fontSize: 15 },
|
||||
container: { flex: 1 },
|
||||
list: { padding: 12 },
|
||||
cardWrapper: { marginBottom: 12 },
|
||||
card: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
@@ -159,16 +287,20 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
dotRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 4 },
|
||||
dot: { width: 10, height: 10, borderRadius: 5 },
|
||||
title: { fontSize: 18, fontWeight: '600', color: '#1c1c1e', flex: 1 },
|
||||
title: { fontSize: 18, fontWeight: '600', flex: 1 },
|
||||
badge: { fontSize: 12, fontWeight: '600' },
|
||||
subtitle: { fontSize: 14, color: '#8e8e93', marginBottom: 4 },
|
||||
time: { fontSize: 13, color: '#007AFF' },
|
||||
status: { fontSize: 13, color: '#34C759', marginTop: 2, fontWeight: '500' },
|
||||
deleteBtn: { alignSelf: 'flex-start', marginTop: 8, paddingVertical: 4, paddingHorizontal: 8 },
|
||||
deleteText: { color: '#FF3B30', fontSize: 13 },
|
||||
center: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#f2f2f7' },
|
||||
empty: { fontSize: 20, color: '#8e8e93', marginBottom: 16 },
|
||||
addBtn: { backgroundColor: '#007AFF', paddingVertical: 12, paddingHorizontal: 24, borderRadius: 12 },
|
||||
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' },
|
||||
editIconBtn: { alignSelf: 'flex-end', padding: 4 },
|
||||
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||
empty: { fontSize: 20, marginBottom: 16 },
|
||||
addBtn: { backgroundColor: '#8B5CF6', paddingVertical: 12, paddingHorizontal: 24, borderRadius: 12 },
|
||||
addBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
|
||||
fab: {
|
||||
position: 'absolute',
|
||||
@@ -177,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,31 +10,38 @@ 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';
|
||||
import { loadOriginStation, saveOriginStation, loadNotificationSettings, saveNotificationSettings, rescheduleAllNotifications } from '../store/eventStore';
|
||||
import { api } from '../services/api';
|
||||
import type { Station, ReminderSettings } from '@timetoleave/core';
|
||||
|
||||
type RootStack = {
|
||||
EventList: undefined;
|
||||
EventDetail: { eventId: string };
|
||||
AddEvent: undefined;
|
||||
Settings: undefined;
|
||||
CalendarImport: undefined;
|
||||
};
|
||||
import type { RootStack } from '../types/navigation';
|
||||
import { useColors } from '../hooks/useColors';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
|
||||
type ScreenProps = {
|
||||
navigation: NativeStackNavigationProp<RootStack, 'Settings'>;
|
||||
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,
|
||||
@@ -59,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);
|
||||
}
|
||||
@@ -74,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 () => {
|
||||
@@ -93,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;
|
||||
}
|
||||
|
||||
@@ -101,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.');
|
||||
return;
|
||||
// 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
|
||||
}
|
||||
|
||||
// Pick the closest stop to the user's actual position
|
||||
// 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]);
|
||||
|
||||
// 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,
|
||||
lat: userLat,
|
||||
lng: userLng,
|
||||
};
|
||||
selectStation(station);
|
||||
await selectStation(station);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -175,136 +223,176 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView
|
||||
style={[styles.container, { backgroundColor: colors.background }]}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Appearance */}
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Appearance</Text>
|
||||
<View style={styles.settingRow}>
|
||||
<Text style={[styles.settingLabel, { color: colors.text }]}>Dark mode</Text>
|
||||
<Switch
|
||||
value={dark}
|
||||
onValueChange={toggleTheme}
|
||||
trackColor={{ true: colors.accent, false: colors.border }}
|
||||
accessibilityLabel="Toggle dark mode"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Origin Station */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Ursprungstation</Text>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Origin station</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Station suchen …"
|
||||
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
|
||||
placeholder="Search station …"
|
||||
placeholderTextColor={colors.subtext}
|
||||
value={query}
|
||||
onChangeText={onQueryChange}
|
||||
autoCapitalize="words"
|
||||
accessibilityLabel="Station suchen"
|
||||
accessibilityLabel="Search station"
|
||||
/>
|
||||
{searching && <ActivityIndicator style={{ marginVertical: 8 }} color="#007AFF" />}
|
||||
{searching && <ActivityIndicator style={{ marginVertical: 8 }} color={colors.accent} />}
|
||||
{searchError && (
|
||||
<Text style={[styles.errorText, { color: '#ff453a' }]}>{searchError}</Text>
|
||||
)}
|
||||
{origin && (
|
||||
<Text style={styles.currentStation}>Aktuell: {origin.name}</Text>
|
||||
<Text style={[styles.currentStation, { color: colors.success }]}>Current: {origin.name}</Text>
|
||||
)}
|
||||
|
||||
{results.map((s) => (
|
||||
<TouchableOpacity key={s.extId} onPress={() => selectStation(s)}>
|
||||
<Text style={styles.resultItem}>{s.name}</Text>
|
||||
<Text style={[styles.resultItem, { color: colors.accent, borderBottomColor: colors.border }]}>{s.name}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
|
||||
<TouchableOpacity style={styles.locBtn} onPress={useCurrentLocation}>
|
||||
<Text style={styles.locBtnText}>📍 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}>
|
||||
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}>Benachrichtigungen</Text>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Notifications</Text>
|
||||
<View style={styles.settingRow}>
|
||||
<Text style={styles.settingLabel}>Benachrichtigungen aktivieren</Text>
|
||||
<Text style={[styles.settingLabel, { color: colors.text }]}>Enable notifications</Text>
|
||||
<Switch
|
||||
value={notifSettings.enabled}
|
||||
onValueChange={toggleNotifications}
|
||||
trackColor={{ true: '#007AFF', false: '#e5e5ea' }}
|
||||
accessibilityLabel="Benachrichtigungen umschalten"
|
||||
trackColor={{ true: colors.accent, false: colors.border }}
|
||||
accessibilityLabel="Toggle notifications"
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.settingLabel}>Pufferzeit (Minuten)</Text>
|
||||
<Text style={[styles.settingLabel, { color: colors.text }]}>Buffer time (minutes)</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.numberInput]}
|
||||
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}>
|
||||
Du wirst {notifSettings.bufferMinutes} Minuten vor der geplanten Abfahrt erinnert.
|
||||
<Text style={[styles.hint, { color: colors.subtext }]}>
|
||||
You will be reminded {notifSettings.bufferMinutes} minutes before the scheduled departure.
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity style={[styles.advancedToggle, styles.locBtn]} onPress={toggleAdvanced}>
|
||||
<Text style={styles.locBtnText}>
|
||||
{showAdvanced ? '↑ Weniger Optionen zeigen' : '↓ Mehr Optionen zeigen'}
|
||||
<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}>
|
||||
<Text style={styles.settingLabel}>Ankunfts-Puffer (Minuten)</Text>
|
||||
<View style={[styles.advancedSection, { borderTopColor: colors.border }]}>
|
||||
<Text style={[styles.settingLabel, { color: colors.text }]}>Arrival buffer (minutes)</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.numberInput]}
|
||||
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}>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}>Zu Fuß-Option anzeigen</Text>
|
||||
<Text style={[styles.settingLabel, { color: colors.text }]}>Show walking option</Text>
|
||||
<Switch
|
||||
value={notifSettings.showWalkingOption}
|
||||
onValueChange={toggleWalking}
|
||||
trackColor={{ true: '#007AFF', false: '#e5e5ea' }}
|
||||
accessibilityLabel="Zu Fuß-Option umschalten"
|
||||
trackColor={{ true: colors.accent, false: colors.border }}
|
||||
accessibilityLabel="Toggle walking option"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.settingRow}>
|
||||
<Text style={styles.settingLabel}>Fahrrad-Option anzeigen</Text>
|
||||
<Text style={[styles.settingLabel, { color: colors.text }]}>Show bike option</Text>
|
||||
<Switch
|
||||
value={notifSettings.showBikeOption}
|
||||
onValueChange={toggleBike}
|
||||
trackColor={{ true: '#007AFF', false: '#e5e5ea' }}
|
||||
accessibilityLabel="Fahrrad-Option umschalten"
|
||||
trackColor={{ true: colors.accent, false: colors.border }}
|
||||
accessibilityLabel="Toggle bike option"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#f2f2f7', padding: 20 },
|
||||
container: { flex: 1 },
|
||||
contentContainer: { flexGrow: 1, padding: 20 },
|
||||
section: { marginBottom: 24 },
|
||||
sectionTitle: { fontSize: 18, fontWeight: '600', color: '#1c1c1e', marginBottom: 10 },
|
||||
sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 10 },
|
||||
input: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5ea',
|
||||
},
|
||||
numberInput: { width: 80 },
|
||||
currentStation: { fontSize: 14, color: '#34C759', marginTop: 6 },
|
||||
currentStation: { fontSize: 14, marginTop: 6 },
|
||||
resultItem: {
|
||||
fontSize: 15,
|
||||
color: '#007AFF',
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#e5e5ea',
|
||||
},
|
||||
locBtn: {
|
||||
marginTop: 12,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: '#e8f4fd',
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
},
|
||||
locBtnText: { fontSize: 15, color: '#007AFF', fontWeight: '500' },
|
||||
locStatus: { fontSize: 12, color: '#8e8e93', marginTop: 6 },
|
||||
locBtnText: { fontSize: 15, fontWeight: '500' },
|
||||
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,
|
||||
@@ -313,9 +401,8 @@ const styles = StyleSheet.create({
|
||||
marginTop: 16,
|
||||
paddingTop: 16,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#e5e5ea',
|
||||
},
|
||||
settingRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 },
|
||||
settingLabel: { fontSize: 14, color: '#1c1c1e' },
|
||||
hint: { fontSize: 12, color: '#8e8e93', marginTop: 6 },
|
||||
settingLabel: { fontSize: 14 },
|
||||
hint: { fontSize: 12, marginTop: 6 },
|
||||
});
|
||||
|
||||
@@ -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,40 +1,164 @@
|
||||
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) => ({
|
||||
return events
|
||||
.filter((evt) => evt.location && evt.location.trim().length > 0)
|
||||
.map((evt) => ({
|
||||
id: evt.id,
|
||||
title: evt.title ?? 'Untitled Event',
|
||||
destination: evt.location ?? '',
|
||||
destination: evt.location!.trim(),
|
||||
eventTime: typeof evt.startDate === 'string' ? new Date(evt.startDate) : (evt.startDate ?? new Date()),
|
||||
source: `native:${evt.calendarId}`,
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 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
|
||||
|
||||
import * as Notifications from 'expo-notifications';
|
||||
|
||||
export default Notifications;
|
||||
export {
|
||||
requestPermissionsAsync,
|
||||
setNotificationHandler,
|
||||
getAllScheduledNotificationsAsync,
|
||||
cancelScheduledNotificationAsync,
|
||||
cancelAllScheduledNotificationsAsync,
|
||||
scheduleNotificationAsync,
|
||||
SchedulableTriggerInputTypes,
|
||||
} from 'expo-notifications';
|
||||
|
||||
// Re-export types from the public package
|
||||
export type {
|
||||
NotificationBehavior,
|
||||
NotificationRequest,
|
||||
NotificationRequestInput,
|
||||
SchedulableTriggerInputTypes as SchedulableTriggerInput,
|
||||
} from 'expo-notifications';
|
||||
@@ -1,156 +1,28 @@
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { SchedulableTriggerInputTypes } from 'expo-notifications';
|
||||
import type { Event, Journey, ReminderSettings } from '@timetoleave/core';
|
||||
|
||||
// Register for push notification permissions
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
}),
|
||||
});
|
||||
import type { Event, Journey } from '@timetoleave/core';
|
||||
|
||||
/**
|
||||
* Calculate leave-by time from event time and journey data.
|
||||
* Uses earliest real departure time if journeys exist, otherwise event time minus buffer.
|
||||
* Calculate the time at which a leave reminder should fire.
|
||||
*
|
||||
* @param event - The event to calculate leave-by time for
|
||||
* @param journeys - Journey data for this event
|
||||
* @param arrivalBufferMinutes - How many minutes before the event to arrive
|
||||
* @param bufferMinutes - How many minutes before leaving to be reminded
|
||||
* 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,
|
||||
bufferMinutes: number
|
||||
reminderBufferMinutes: number,
|
||||
): Date {
|
||||
// Calculate target arrival time (event time minus arrival buffer)
|
||||
const targetArrivalTimeMs = event.eventTime.getTime() - arrivalBufferMinutes * 60 * 1000;
|
||||
const validJourneys = journeys.filter((journey) => !journey.cancelled);
|
||||
|
||||
// If we have journeys, use the earliest non-cancelled real departure minus reminder buffer
|
||||
if (journeys.length > 0) {
|
||||
const best = journeys
|
||||
.filter((j) => !j.cancelled)
|
||||
.sort((a, b) => a.rD.getTime() - b.rD.getTime())[0];
|
||||
if (validJourneys.length > 0) {
|
||||
const earliestDeparture = validJourneys.reduce((earliest, journey) =>
|
||||
journey.rD.getTime() < earliest.rD.getTime() ? journey : earliest,
|
||||
);
|
||||
|
||||
if (best) {
|
||||
// Leave by time = earliest real departure time - reminder buffer
|
||||
return new Date(best.rD.getTime() - bufferMinutes * 60 * 1000);
|
||||
}
|
||||
return new Date(earliestDeparture.rD.getTime() - reminderBufferMinutes * 60_000);
|
||||
}
|
||||
|
||||
// Fallback: event time minus arrival buffer minus reminder (no journey data)
|
||||
return new Date(targetArrivalTimeMs - bufferMinutes * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule notifications for an event
|
||||
*
|
||||
* @param event - The event to schedule notifications for
|
||||
* @param journeys - Journey data for this event (optional)
|
||||
* @param settings - Notification settings
|
||||
*/
|
||||
export async function scheduleNotificationsForEvent(
|
||||
event: Event,
|
||||
journeys: Journey[] = [],
|
||||
settings: ReminderSettings
|
||||
): Promise<void> {
|
||||
// Don't schedule if notifications are disabled
|
||||
if (!settings.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate leave-by time (when user should actually leave)
|
||||
const leaveByTime = calculateLeaveByTime(event, journeys, settings.arrivalBufferMinutes, settings.bufferMinutes);
|
||||
|
||||
// Cancel existing notifications for this event - cancel one by one
|
||||
const existing = await Notifications.getAllScheduledNotificationsAsync();
|
||||
const toCancel = existing.filter(n => n.content.data?.eventId === event.id);
|
||||
if (toCancel.length > 0) {
|
||||
for (const notif of toCancel) {
|
||||
await Notifications.cancelScheduledNotificationAsync(notif.identifier);
|
||||
}
|
||||
}
|
||||
|
||||
// Default reminders: 30min, 10min, and at leave-by time
|
||||
// But respect the buffer time - we want reminders relative to when they should leave
|
||||
const defaultReminders = [30, 10, 0];
|
||||
|
||||
// Schedule notifications
|
||||
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 Date trigger with time property
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: `🚆 ${event.title}`,
|
||||
body: minutesBefore === 0
|
||||
? 'Zeit zu gehen!'
|
||||
: `${minutesBefore} Minuten bis du losmusst`,
|
||||
data: { eventId: event.id },
|
||||
},
|
||||
trigger: { type: SchedulableTriggerInputTypes.DATE, date: triggerTime },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reschedule notifications for all events
|
||||
* Use this when origin station changes or notification settings are updated
|
||||
*/
|
||||
export async function rescheduleAllNotifications(
|
||||
events: Event[],
|
||||
journeysMap: Record<string, Journey[]>, // eventId -> journeys
|
||||
settings: ReminderSettings
|
||||
): Promise<void> {
|
||||
// Cancel ALL existing notifications first
|
||||
await Notifications.cancelAllScheduledNotificationsAsync();
|
||||
|
||||
// Schedule new notifications for each event
|
||||
for (const event of events) {
|
||||
const eventJourneys = journeysMap[event.id] || [];
|
||||
await scheduleNotificationsForEvent(event, eventJourneys, settings);
|
||||
}
|
||||
}
|
||||
|
||||
// Request permissions if not already granted
|
||||
let permissionsRequested = false;
|
||||
|
||||
export async function requestNotificationPermissions(): Promise<boolean> {
|
||||
if (permissionsRequested) {
|
||||
return true;
|
||||
}
|
||||
|
||||
permissionsRequested = true;
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
return status === 'granted';
|
||||
}
|
||||
|
||||
// Request permissions automatically when app starts (for Android)
|
||||
// This is called in App.tsx
|
||||
|
||||
export function setupNotifications() {
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
}),
|
||||
});
|
||||
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,15 +1,23 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { Event, Station, ReminderSettings } from '@timetoleave/core';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { SchedulableTriggerInputTypes } from 'expo-notifications';
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// ── Keys ───────────────────────────────
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { DEFAULT_ORIGIN_STATION, type Event, type Station, type ReminderSettings } from '@timetoleave/core';
|
||||
import * as Notifications from '../services/expoNotifications';
|
||||
|
||||
// ── Keys ───────────────────────────────────────────────────────
|
||||
|
||||
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 ─────────────────────
|
||||
// ── Default notification settings ─────────────────────────────
|
||||
|
||||
const DEFAULT_NOTIFICATION_SETTINGS: ReminderSettings = {
|
||||
bufferMinutes: 30,
|
||||
@@ -19,8 +27,12 @@ const DEFAULT_NOTIFICATION_SETTINGS: ReminderSettings = {
|
||||
showBikeOption: true,
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────
|
||||
// ── 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 }>;
|
||||
@@ -35,10 +47,15 @@ async function getNotificationSettings(): Promise<ReminderSettings> {
|
||||
return json ? JSON.parse(json) : DEFAULT_NOTIFICATION_SETTINGS;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// 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);
|
||||
@@ -48,53 +65,56 @@ 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;
|
||||
}
|
||||
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 },
|
||||
},
|
||||
trigger: { type: SchedulableTriggerInputTypes.DATE, date: triggerTime },
|
||||
trigger: { type: Notifications.SchedulableTriggerInputTypes.DATE, date: triggerTime },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Events ────────────────────────────────────────────
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// ── Events ────────────────────────────────────────────
|
||||
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
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function loadEvents(): Promise<Event[]> {
|
||||
const json = await AsyncStorage.getItem(EVENTS_KEY);
|
||||
@@ -113,6 +133,17 @@ export async function addEvent(event: Event): Promise<void> {
|
||||
await scheduleEventNotification(event);
|
||||
}
|
||||
|
||||
export async function updateEvent(id: string, updates: Partial<Event>): Promise<void> {
|
||||
const events = await loadEvents();
|
||||
const updated = events.map((e) => (e.id === id ? { ...e, ...updates } : e));
|
||||
await saveEvents(updated);
|
||||
// Reschedule notification for updated event
|
||||
const updatedEvent = updated.find((e) => e.id === id);
|
||||
if (updatedEvent) {
|
||||
await scheduleEventNotification(updatedEvent);
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeEvent(id: string, onDone?: () => void): Promise<void> {
|
||||
const events = await loadEvents();
|
||||
const filtered = events.filter((e) => e.id !== id);
|
||||
@@ -128,12 +159,13 @@ export async function removeEvent(id: string, onDone?: () => void): Promise<void
|
||||
onDone?.();
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Origin Station ────────────────────────────────────
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Origin Station
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
|
||||
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> {
|
||||
@@ -141,8 +173,9 @@ export async function saveOriginStation(station: Station): Promise<void> {
|
||||
await AsyncStorage.setItem(ORIGIN_KEY, json);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Notification Settings ──────────────────────────────
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Notification Settings
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function loadNotificationSettings(): Promise<ReminderSettings> {
|
||||
const json = await AsyncStorage.getItem(NOTIFICATIONS_KEY);
|
||||
@@ -156,49 +189,49 @@ export async function saveNotificationSettings(
|
||||
await AsyncStorage.setItem(NOTIFICATIONS_KEY, json);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 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;
|
||||
}
|
||||
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: `🚆 ${event.title}`,
|
||||
body: minutesBefore === 0
|
||||
? 'Zeit zu gehen!'
|
||||
: `${minutesBefore} Minuten bis du losmusst`,
|
||||
data: { eventId: event.id },
|
||||
},
|
||||
trigger: { type: SchedulableTriggerInputTypes.DATE, date: triggerTime },
|
||||
});
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Shared navigation types for the entire app.
|
||||
* Import from here instead of duplicating in each screen.
|
||||
*/
|
||||
export type RootStack = {
|
||||
EventList: undefined;
|
||||
EventDetail: { eventId: 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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||